mcpField
V
visant-motion

visant-motion

github.com/pedrojaques99デザイン・UI8115 回インストール1 日前に更新
リポジトリ

visant-motion について

インターフェース制作者向けのモーション設計スキルです。リポジトリ既存の設定を基準にし、時間、イージング、動きを減らす設定、オーバーレイ、待機状態の規則を示します。

主な機能

  1. 01リポジトリのモーション設定を再利用
  2. 02状況に応じたイージングと時間
  3. 03transform と opacity を優先したアニメーション
  4. 04動きを減らす設定への対応
  5. 05読み込みと待機状態の設計

活用シーン

  1. 01インターフェースの動作を監査
  2. 02共有設定を使ったトランジション実装
  3. 03トリガーに沿ったオーバーレイ動作
  4. 04読み込みと待機のフィードバック設計

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/pedrojaques99/visant-skills-public --skill visant-motion

インストール後、Claude Code で/visant-motionを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

assets/motion-ssot-starter/
use-delayed-flag.ts
motion.ts
globals-tokens.css
references/
cases.md
motion-rules.md
scripts/
measure-motion.mjs
codemod-transition-all.mjs
codemod-duration.mjs
motion-lint.mjs
scripts/__fixtures__/
bad.tsx
bad.css
leak.css
suppressed.css
suppressed.tsx
scripts/__fixtures__/good/
good.css
good.tsx
guard.test.tsx

詳細

Visant Motion

動きが自然で、遅いと感じさせないインターフェースを構築します。すべての遷移には目的があり、即座に反応します。基本ルール:SSoT を徹底し、コンポーネントに cubic-bezier や duration をハードコードしないでください。

実際に必要なのは §1 と §2 だけです。 それ以降は特定の場面で参照するための内容です。SSoT の初期設定は §10 にあります。これはリポジトリごとに一度だけ必要になるためです(理由:references/cases.md)。

原則の出典: Emil Kowalski のスキル(https://github.com/emilkowalski/skills、`emil-design-eng`、`apple-design`、`improve-animations`、`review-animations`)を、animations.dev と Apple の Designing Fluid Interfaces(WWDC 2018)からまとめたものです。上流の正式なガイダンスについては、該当リポジトリを参照してください。このスキルは、Visant 向けに適用し、SSoT 化したものです。

0. 対象リポジトリのモーション SSoT を最初に探す

モーションを実装または監査する前に、リポジトリ内でモーションを管理している場所を見つけます。次を確認してください。

  • イージングと時間をエクスポートする JS/TS モジュール:EASE_OUT、cubic-bezier、DUR、transitions を検索します(よくある名前:lib/motion.ts、src/lib/motion.ts、utils/animation.ts)。
  • CSS カスタムプロパティ:グローバルスタイルシートで --ease- と --dur- を検索します。
  • すでにモーションを備えた共有プリミティブ:Button(押下フィードバック)、オーバーレイのラッパー(ポップオーバー/ドロップダウン/ダイアログ)、トーストライブラリ(sonner)、遅延ローダーのフック。

見つかった場合は → それを使い、以下のシンボル名を適宜置き換えてください。並行するシステムは作成しないでください。見つからない場合は → §10。以下にある lib/motion.ts/@/lib/motion はすべて、実際のパスにかかわらず「リポジトリのモーション SSoT」を指します。

1. 判断ルール(簡易版)

次の順に判断します。監査向けの詳しい数値カタログと重大度 → references/motion-rules.md。

  1. アニメーションは必要ですか? 1 日に 100 回以上使う操作(ショートカット、トグル、⌘K)→ 使わない。使用頻度が低いもの(モーダル、トースト)→ 標準。まれなもの(オンボーディング)→ 演出を加えてもかまいません。
  2. イージング: 表示/終了 → EASE_OUT。画面上での移動 → EASE_IN_OUT。ホバー/色 → ease。一定の動き(マーキー/シマー/プログレス)→ linear。UI では ease-in を使わないでください。例外は 1 つだけです(§5 のルール 2)。
  3. 時間: UI は 300ms 未満。押下 100-160・ドロップダウン 150-250・モーダル 200-300。画面遷移(§5)、マーケティング、スクロール、3D では長く意図的な動きが許容されます。上限は、クリックへの応答に関するものです。
  4. scale(0) は使わない: scale(.95) と不透明度から開始します。
  5. transform と opacity だけを使う(GPU)。width/height/margin/padding はアニメーションさせないでください。
  6. スプリングは、慣性やジェスチャー(ドラッグ、フリック)にのみ使います。メニューのフェードには、スプリングではなく時間を指定します。
  7. 中断可能な方法は CSS トランジション/スプリングです。アニメーションが最初から再開される @keyframes は使いません。

2. 注意点(気づきにくいものの、実戦で検証済み。実装前にお読みください)

  1. duration-[var(--dur-x)] はビルド時に「ambiguous(曖昧)」警告を出します(Tailwind は transition-duration と animation-duration を区別できません)。プロパティを明示してください:[transition-duration:var(--dur-base)]。
  2. framer-motion は CSS の transform を上書きします。 motion.div が y/scale をアニメーションさせる場合、Tailwind の hover:-translate-y-* はインラインの transform によって黙って無効になります。対処法:transform は framer に任せ(whileHover={{y:-2}} + whileTap)、CSS ではシャドウ/ボーダーのみを扱います。
  3. whileTap には独自の transition が必要です。 そうしないと、(遅い)enter transition を引き継ぎます:whileTap={{ scale:.985, transition:{ duration: DUR.press, ease: EASE_OUT } }}
  4. transition-all は絶対に使わないでください:予期しないプロパティまでアニメーションし、動作がぎこちなくなります。対象を限定してください:transition-colors、または transition-[color,background-color,border-color,box-shadow,opacity]、または transition-transform。リポジトリ全体を一括修正するには codemod(§7)を使います。
  5. オーバーレイの原点を考慮します: Radix は --radix-<primitive>-content-transform-origin を公開しています。origin-[var(--radix-popover-content-transform-origin)](および dropdown-menu/select/context-menu/tooltip/hover-card)を追加し、トリガーを起点に拡大するようにします。例外:モーダルは transform-origin: center のままにします(トリガーに固定されていません)。
  6. モーション軽減は「オフ」ではなく、実際のシグナルです。 不透明度/色は維持し、移動/スプリングは取り除きます。グローバルな @media (prefers-reduced-motion) { *{…0.001ms} } ガードは、手軽な安全策です。リスト行の exit では、行は引き続き消えます。アニメーションをゼロにするのであって、結果をなくすのではありません。
  7. 開いているモーダルの背後で exit アニメーションを再生するのは無駄で、閉じる処理を遅らせます。 行がリストから折りたたまれて消えることが主な効果となるダイアログで項目を完了する場合、単純な順序では約300msの exit(行に completing フラグを設定)を待ってからモーダルを閉じます。その結果、折りたたみはモーダルの背後で進み、モーダルは約300ms何もせずにとどまります。先にサーフェスを閉じてください:モーダルが閉じる動き自体が強い exit となり、その後に行が見える状態で折りたたまれます。カンバンカードの列が変わるときに定型的な動きを省くのと同じルールです(列の移動そのものが exit です)。1つのサーフェスにつき、見える exit は1つだけにし、2つ重ねないでください。パターン → visant-frontend › exit の動きを始める前にサーフェスを閉じる。
  8. 素人っぽく見えるのは、持続時間ではなく振幅です。 大きく回転し、行き過ぎて、パーティクルが大量に散る削除アニメーションは素人っぽいとして却下されました。修正ではタイミングを保ちつつ、振幅を抑えました(回転は約12°、行は40%まで縮小、隣接要素の移動は≤ 4px、消える要素に行き過ぎはなし)。ルール:振幅を半分にし、さらにもう一度半分にします。優れたモーションはほとんど気づかれません。全ケース:cases.md。
  9. 1つのサーフェスにつき、モーションの焦点は1つです。 キューのカウント、画像のストリーミング表示、エクスポートの進捗率のカウント、エラー時の揺れ、トーストのスライドが同時に起きると、動きが多すぎる印象になります。状態を順番に切り替え(送信済み → 生成中 → 準備完了 → エクスポート中)、一度に動くものは1つだけにします。
  10. 最適な状態から始めます。 ホバー時にだけ現れる傾き/輝き/遠近感は、タッチ操作、グリッドのサムネイル、スクリーンショットでは存在しません。ヒーローのポーズから始め、ホバーではそれを強めるだけにします。初めて現れる演出にはしません。
  11. 拡大縮小されたステージは、モーションコードに誤った値を与えます。 transform: scale のコンテナー(コンポーネントのショーケースや拡大プレビューなど)内では、ポインターの移動量をスケールで割る必要があります。position: fixed は変形後のボックスを基準に計測されるため、body にポータルで配置します。また、R3F キャンバスには resize={{ offsetSize: true }} が必要です。scale(0.5) でドラッグ操作を必ずテストしてください。

2b. サウンド(SFX):アナログなクリック音。操作がある場所だけ

サウンドは耳で感じる動きであり、同じ上限を守ります。共通エンジンは sfx-bus です(Web Audio による合成、ファイルもリクエストも不要です。Visant ライブラリの ui.visantlabs.com から npx shadcn@latest add @visant/sfx-bus を実行できます。ライブラリには motion-layer、fold-motion、line-reveal、text-morph、count-up、toast-stack など、すぐに使えるモーションパーツも含まれています)。まず、対象のリポジトリにサウンドレイヤーがすでにあるか確認してください。

  • 各音色は、シンセの音ではなく、実際のアナログ機器をモデルにします。 高 Q のバンドパスフィルターを通したノイズのバースト(モーダル合成)や、機構音を表現するための、タイミングをずらした複数のヒットを使います。音色の種類は、click(カメラのボタン)、detent(ノブやセレクターの歯)、toggle(本体を伴うレバー)、focus(連続ドラッグ用のレンズのフォーカスリングのラチェット)、complete(操作確定時のシャッターの作動と解放)、drop(強い操作用の針の着地や柔らかな空気音)です。音程や余韻は付けません。残響のある音程付きの音は、インターフェースで聞き続けると疲れてしまいます。
  • ジェスチャーを音色に対応付けます。 離散的なステップ(ノブ、タブ、ドックアイコンを通過する動き)には detent、連続ドラッグ(スライダー)には移動量の 1/20~1/40 ごとに focus、スイッチには toggle、終端に到達するまでの長押しやスライドによる確定には complete を使います。ドラッグのすべてのピクセルで音を鳴らしてはいけません。また、何分もかけて調整するパラメトリックカーブは無音のままにしてください。
  • デフォルトではホバー時に鳴らしません。 音を鳴らすのは、操作がある場所だけです。有効な例外は、ポインターがアイコンを横切ると小さな detent が鳴るドックです。間隔は約 45 ms に制限します。
  • 自動再生ではなく、操作後に有効化します。 AudioContext は実際のジェスチャー後にのみ作成し(navigator.userActivation)、prefers-reduced-motion が有効な場合はミュートします。保存済みの設定があればそちらを優先し、音色ごとに最小間隔を設け、12 回のヒットがループのように聞こえないようにランダムなオフセットと ±6% のピッチ変化を加えます。(pointer: fine) の場合にのみ有効にし、タッチではトグルを非表示にします。
  • 音量: すべての音色を、一般的な UI のクリック音(RMS -44 dBFS)より小さくします。OfflineAudioContext でレンダリングして測定し、ソースコメント内の表に数値を記載してください。

3. API(スターターの lib/motion.ts。リポジトリ独自の名前に対応付けてください)

import { EASE_OUT, EASE_IN_OUT, EASE_DRAWER, DUR, transitions,
         fadeInUp, fade, itemEnter, pressable } from "@/lib/motion";
シンボル値用途
EASE_OUT[0.23,1,0.32,1]入場/退出、入力に応答:デフォルト
EASE_IN_OUT[0.77,0,0.175,1]画面上の移動/変形(入場/退出には使いません)
EASE_DRAWER[0.32,0.72,0,1]シート/ドロワー(iOS 風)
DUR{press:.12, fast:.16, base:.2, slow:.3}期間のスケール(秒)
transitions.*fast/base/slow/spring/springSoftすぐに使えるトランジションオブジェクト
fadeInUp, fadeバリアント(hidden/show)共通の入場アニメーション
itemEnter(i, step=.04, cap=.4)(関数)リスト項目の入場、短く上限を設けたスタガー
pressable(オブジェクト)クリック可能なカード/タイルに控えめな whileTap を適用

CSS 形式(プロパティを明示。注意点 #1 を参照):[transition-duration:var(--dur-base)] [transition-timing-function:var(--ease-out)]。

4. 待機は画面の一部です(保留状態をすべて設計する)

ユーザーが待つ間には、設計したかどうかにかかわらず、常に動きがあります。何も考えずに置かれたスピナーは、誰も選んでいないデフォルトにすぎません。待機は§1ルール1でいう発生頻度の低いものでもあるため、ここにこそ楽しさの予算を使えます。イラスト、ブランドキャラクター、眺めていたくなるループなどです。魅力が作業の邪魔にならない、製品の中で唯一の場所です。

都合のよさではなく、待ち時間の長さに合わせて選びます。

待ち時間デザイン決してしないこと
100ms未満何も表示しない:楽観的な書き込み+取り消しトーストスピナー(表示されても一瞬です)
100~300ms現在のUIを維持する。押下フィードバックで既に応答しています画面をローダーに切り替える
0.3~1s最終レイアウトのスケルトンを表示し、コンテンツにクロスフェードするスピナー、表示時のレイアウトジャンプ
1~5sスケルトン+段階的な表示。パーセンテージが実際の値である場合に限り、確定的なプログレスバーを使う90%で止まる偽の進捗表示
5s以上手順を説明しながら表示し、イラストやループを添える。現在の処理内容にラベルを付ける1分間、静止したスピナーを1つだけ表示する
不明部分的な結果をストリーミングする。コンテンツが届いてくること自体がローダーです不確定なものを使う
  1. サーバーの応答より先に、クリックに応えます。 押下フィードバック+即時の状態変化が、ローディングの最初のフレームです。楽観的なパッチ+取り消し(§6)は、どのスピナーよりも優れています。また、クリック時に画面が閉じる場合、ボタン内のスピナーはまったく表示されないため、意味のない飾りです:visant-frontend › 到達不能なスピナー。
  2. ちらつき防止の組み合わせ: ローダーは約300ms経過するまで表示せず、一度表示したら約500ms維持します。ちらつくローダーは、速さではなくバグに見えます。
  3. スケルトンは実際のレイアウトに近づけます(ボックスのサイズ、角丸、間隔を同じにします)。そうすれば、コンテンツがずれずに表示されます。シマーはlinear、無限ループ、約1.2~1.6sにします。一定の動きです(§1ルール2)。アプリ内で正当に使える唯一の@keyframesです。これを中断するものはないからです。
  4. スケルトンからコンテンツへ、一気に切り替えてはいけません。 DUR.baseでクロスフェードし、最初のブロックだけに小さなyを付けます。それ以外はすでに配置されています。
  5. 空の状態も待機です。ユーザーの最初のデータを待っています。同じように扱い、イラストと待機を終わらせる唯一のアクションを用意します。(そもそも空の状態にスペースを割くかどうかは、このスキルではなくvisant-frontendが決めます。)
  6. 動きを減らす設定: シマーやパルスは、静的な色合い、またはゆっくりした不透明度のフェードに置き換えます。進捗表示は残します。これは装飾ではなく情報です。
  7. 1つの画面につき、待機アニメーションは1つだけにします。 スケルトンの行+スピナー+プログレスバーは、1つの問いに対する答えが3つある状態です。
  8. 表示にかかる時間を、待ち時間より長くしてはいけません。 200msの取得処理に対して400msの表示演出を加えると、自分で遅延を作ったことになります。
  9. 待機の終わりには結果があります。失敗する場合も設計します。 スケルトンからエラーへの遷移は誰もが忘れがちですが、最も強く印象に残ります。スケルトンは3秒間、コンテンツが表示されると期待させているからです。レイアウトを崩さず、その場でスケルトンをエラーに置き換え、ボックスは維持し、再試行を中に配置します。同じクロスフェードを使い、跳ねさせず、赤く点滅させません。エラーは情報であって、恐怖を与えるものではありません。

時間のかかる処理向けのプログレスメーター、ゲージ、スパークライン形式の表示は**visant-refine**が描画します。このセクションで扱うのは、そのタイミングだけです。

5. ナビゲーションと共有要素(ルート間の移動)

300 ミリ秒未満という上限は、クリックに応答するコントロールに適用されます。ルート間の移動は別物で、ここでウェブアプリがウェブアプリらしく感じられなくなります。

これを一から書かないでください。 Vercel はパターンと CSS 全体を公式スキルとして管理しています。インストールして、方法についてはそれに従ってください。

npx skills add https://github.com/vercel-labs/agent-skills --skill vercel-react-view-transitions

このセクションでは、いつ使うかと、プロジェクト独自の調整を扱います。ユーザーに異なる意味を伝える 4 つのパターンがあります。

パターン伝える意味使用する場面
共有要素のモーフィング(<ViewTransition name>)「同じオブジェクトを、さらに深く見る」サムネイル → 詳細、行 → レコードページ
Suspense による表示(境界での enter/exit)「データが届いた」§4 のスケルトンからの引き継ぎを、ルート規模で行う場合
方向性のあるスライド(<Link> の transitionTypes)「進む/戻る」表現すべき実際の階層構造がある場合
同一ルートのクロスフェード(key={slug})「同じ場所で、コンテンツが変わる」タブ、フィルター、期間の切り替え

プロジェクトのルール:

  1. 時間は --dur-vt-* に定義し、インラインには書きません。退出は 150、入場は 210(退出後に開始)、移動は 400 です。この非対称性が重要です。以前のビューはすでに議論に負けているので素早く去り、新しいビューは読める程度に落ち着いて現れます。
  2. 「ease-in は使わない」の唯一の例外は ::view-transition-old です。 退出を待つものはないため、静止状態を前半に寄せてもコストはかかりません。それ以外では禁止を徹底し、motion-lint にはこの例外が正確に組み込まれています。
  3. 名前付きのペアにはすべて default="none" を指定します。 指定しないと、無関係な遷移でも名前付きの ViewTransition がアニメーションします。落とし穴への対策として、default="none" を指定して明示的な share を設定しないと、モーフィングがひそかに動作しなくなります。
  4. 何かを固定基準にします。 ヘッダーをスライドさせてはいけません。固定基準がないと、ユーザーには「コンテンツが変わった」ではなく「ビューポート全体が飛んだ」と受け取られます。
  5. ラッパーは page.tsx に置き、レイアウトには置きません(Next App Router)。レイアウトはナビゲーションをまたいで維持されるため、そこで入場/退出は発生しません。手作業で調べると、原因の特定に 1 時間かかります。
  6. ::view-transition { pointer-events: none } を指定します。スターター CSS にすでに含まれています。これがないと、400 ミリ秒の移動中にタップしても、何も起きずに無視されます。
  7. ブラウザーの戻る操作には遷移タイプがありません。 自分のリンクでしか成立しない意味を組み込まないでください。
  8. モーションを減らす設定には専用のルールが必要です。グローバルな * ガードは、ビュートランジションの疑似要素(ドキュメントにぶら下がっています)には適用されません。方向性のあるスライドは、乗り物酔いに似た症状を最も引き起こしやすい動きです。このガードだけは省略できません。こちらもスターター CSS に含まれています。
  9. モーフィングがすべてナビゲーションというわけではありません。 行の並べ替え、カードの列の変更、リスト間での項目の移動などでは、ナビゲーションは発生していないため、View Transitions は適切な手段ではありません。こうした場合は framer-motion の layout/layoutId を使います(FLIP を使うため、サイズ変更は transform で実行され、§1 のルール 5 に沿った動作になります)。把握しておくべきコストが 2 つあります。長いリストに layout を指定すると、コミットのたびにすべての子要素を再計測するため、実際に動く要素に対象を絞ってください。また、layoutId のペアは同じ AnimatePresence 内にマウントされていなければならず、そうでないとモーフィングは単純なクロスフェードに劣化します。目安:ルートが変わった → View Transitions、DOM がその場で並べ替わった → layout。

スクロール連動のモーション: スクロール用ライブラリより、ネイティブの animation-timeline: scroll()/view() を優先してください。メインスレッドの外で実行されるため、カクつきません。@supports (animation-timeline: view()) で対応状況を判定し、JS による処理はデフォルトではなくフォールバックとして残してください。スクロールやマーケティングのモーションは長く、意図的なものでもかまいません。監査のうえ維持されているものは、不具合ではありません。

6. 作り直す前に再利用する

新しいものを書く前に、対象のリポジトリで以下を確認してください(実際のアプリを埋め込んだ例は cases.mdにあります)。

  • 共有 Button の押下フィードバック(active:scale-[0.97] など)。設定されていれば、すべてのボタンにすでに適用されています。ボタンごとに追加しないでください。設定されていない場合は、共有プリミティブに一度だけ適用してください。
  • オリジンを考慮する共有オーバーレイ。 新しいカスタムオーバーレイを作る場合も、同じ Radix 変数を適用してください。
  • フィードバックパターン(多くの場合、楽観的 UI + sonner のようなトースト + 元に戻す機能)。これを再利用し、モーダルでの確認を新たに作らないでください。
  • 完了時の一連の処理(祝う → 行を退出させる → 楽観的にパッチを適用する → 元に戻す)。これを管理するフック(use*Actions、useComplete*、リストの背後にあるミューテーションフック)を grep で探してください。アイテムの完了や操作が必要な新しい画面では、同じデータキーに対してそのフックを呼び出します。一連の処理を再実装すると、件数がいつの間にか一致しなくなります。高頻度で変化するサブ値(動作中の時計)は専用のコンテキストに分け、一連の処理を利用するコンポーネントが毎秒再レンダリングされないようにしてください。visant-frontend › 秒ごとに変化する値を専用のコンテキストに分ける。

7. ツール:lint を実行してから codemod

motion-lint.mjs:重大度テーブルを実行可能にする

監査ルールは、適用する仕組みがなければ機能しません。scripts/motion-lint.mjs は、HIGH/MEDIUM のカタログを、ファイル名と行番号、理由、修正方法を含む12個の正規表現ルールとして実行します。依存関係も設定も不要です。

node scripts/motion-lint.mjs                     # human output, cwd
node scripts/motion-lint.mjs src app --strict    # fail on MEDIUM too
node scripts/motion-lint.mjs --json              # for CI or an agent

HIGH が1つでもあると終了コード1を返します(--strict を付けると MEDIUM も対象)。そのまま pre-push フックや CI に組み込めます。実際に存在する例外も認識します。::view-transition-old の ease-in、300ms を超える --dur-vt-*、SSoT ファイル内の cubic-bezier、useDelayedFlag をインポートするファイル内のローダーなどです。ある行に対してルールが実際に誤判定となる場合(長時間の意図的なマーケティング用またはスクロール用モーションなど)は、理由を付けて抑制してください。

// motion-lint-disable-next-line slow-ui-duration  scroll-driven hero, deliberate

ルール:ease-in-on-ui · scale-zero · transition-all · slow-ui-duration · hardcoded-curve · hardcoded-duration · layout-property-animation · background-position-shimmer · whiletap-inherits-transition · loader-without-guard · fake-progress · popover-origin。

ルールを編集したら、必ず node scripts/motion-lint.mjs --self-test を実行してください。 正規表現のテストスイートが時間とともに気付かれずに失う、次の3つの動作をチェックします。すべてのルールが scripts/__fixtures__/bad.* に対して引き続き検出されること、正常なフィクスチャで誤検知なく0件が返ること、抑制が引き続き機能することです。ルールがいつの間にか一致しなくなるのは、ルールがないよりも悪い状態です。監査結果が問題なしになってしまいます。新しいルールを追加するたびにフィクスチャケースも追加してください。ケースがないルールがあると、セルフテストは失敗します。

--fix:機械的な修正を1つのコマンドで実行
node scripts/motion-lint.mjs --fix <target>   # runs both codemods below

実際の監査の大半は判断を必要としません(ある実施例では55件中48件が、トークンへの置き換えが必要なインラインの時間指定でした)。--fix は入口であり、安全に書き換えられる内容の判断は codemod が担います。意図的に手を付けないものは、UI 上の ease-in、scale(0)、オリジンを考慮しないポップオーバー、点滅するローダー、設計されていない待機時間です。これらはデザイン上の判断であり、単なるテキスト置換ではありません。

codemod-duration.mjs:インラインの時間指定 → DUR.*
node scripts/codemod-duration.mjs                 # dry-run: the plan, in three buckets
node scripts/codemod-duration.mjs --apply         # exact + near
node scripts/codemod-duration.mjs --apply --over  # also >300ms (read the list first)

書き換えが同等ではないため、3つの分類があります。EXACT:数値がすでにトークンと一致します(0.2 → DUR.base)。単純な名前の置き換えです。NEAR:トークンとの差が25%以内で、300ms 未満です(0.25 → DUR.base)。OVER:300ms を超えます(0.6 → DUR.slow)。この場合、アニメーションの長さが半分になります。これはリファクタリングではなく動作変更なので、--over を指定し、対象リストを確認する必要があります。ルート遷移やマーケティング用途には正当な例外があり、画面を見ている人にしか判断できません。

どのトークンからも大きく外れる値は、推測で置き換えず UNMAPPED のままにします。0.08 は意図的に控えめな押下表現かもしれません。これを黙って DUR.press に合わせると、判断を記録するどころか消してしまいます。インポートは、リポジトリで SSoT に使用しているパスに合わせて追加されます。

codemod-transition-all.mjs: 2番目に大きな問題を一掃する

scripts/codemod-transition-all.mjs は transition-all を必要最小限の正しい対象に書き換えます。インタラクティブな状態で transform をアニメーションさせる要素はスキップし、共有の ui/** プリミティブは除外します。ガード付きで冪等性があり、まずはドライランで実行します。

node scripts/codemod-transition-all.mjs          # dry-run: prints the plan + skips
node scripts/codemod-transition-all.mjs --apply  # writes changes

適用する前に、対象リポジトリの構成に合わせて、先頭の ROOTS/isUi 定数を調整してください。実行後は必ず tsc、テスト、ビルドを実行してください。

8. 使用感の確認:URL を計測してから、目で判断する

まず、デプロイ済みの URL で計測してください。 ソースのスキャンでは、カクつくページでも問題なしと判定されます。実際に動作しているページだけが真実を示します。

node scripts/measure-motion.mjs https://app.example.com
node scripts/measure-motion.mjs <url> --click "[data-testid=row],#open"
node scripts/measure-motion.mjs <url> --reduced-motion --no-inp

ページを操作し(ホバー → クリック → エスケープ → スクロール)、デバイスで実際に起きたことを報告します。

  • INP:web-vitals を使って属性情報とともに計測します。内訳を確認してください。presentation が大半を占めるなら、コストの原因はアニメーションです。processing が大半を占めるなら、原因はハンドラーです。バグが異なれば、修正方法も異なります。
  • Long Animation Frames(Chromium 123+):スクリプトごとの属性情報を表示します。フレームをブロックした処理と、その原因となった関数を確認できます。ユーザーが感じるのに、報告されることのないカクつきです。
  • モーション低減の監査: reduce を強制したとき、まだ 100ms を超えて動作しているものを漏れとして一覧表示します。手作業では誰も確認しないチェックであり、スターター CSS に個別のビュー遷移ガードが含まれている理由でもあります。

予算を超えると終了コード 1 を返します(--budget-inp 200、--budget-blocking 100)。そのため、デプロイのゲートとして使用できます。

次に、目で判断してください。数値では判断できません。DevTools → Animations → 25% speed で確認します。押したときに瞬時に縮小するか? ポップオーバーがトリガーから広がるか? クロスフェード中に 2 つの状態が重なっていないか? スタガーが 1 つずつではなく、短い連続動作として感じられるか? 翌日、目を新鮮な状態にしてもう一度確認してください。

9. コードベースの監査

§0(SSoT を見つける)から始め、次に node scripts/motion-lint.mjs --json(§7)を実行してください。重要度と修正方法を付けた grep の全検索を代わりに行います。その後、デプロイ済みの URL を計測します(§8)。コードを読むのはその後です。ツールが指摘した箇所だけを確認してください。

lint では確認できないことが 2 つあるため、手作業で監査してください。待機状態(すべての isLoading/isPending/Suspense/空の分岐を列挙し、それぞれの間にユーザーに何が表示されるかを確認します。§4)と、ナビゲーション(ソース内のパターンではなく、遷移が実際の内容を正しく伝えているかで判断します。§5)です。次に references/motion-rules.md を確認し、監査カテゴリ、HIGH/MEDIUM の重要度基準、具体的な修正方法を確認します。影響度 ÷ 作業量を基準に、効果の大きい順に問題を報告してください。まずコア/共有プリミティブを修正してください(すべてのルートに反映されます)。長く意図的なマーケティング/スクロール/3D アニメーションは監査して維持し、問題として指摘しません。

10. SSoT の初期設定(§0 で見つからなかった場合のみ)

モーションは 1 か所に集約し、JS と CSS で同じ定義を使用して、framer-motion と Tailwind/Radix の動作を一致させます。リポジトリで共有ライブラリを管理している場所にファイルを置いてください(tsconfig.json の @/ エイリアスを確認してください)。

  • JS: assets/motion-ssot-starter/motion.ts → 例:lib/motion.ts(イージング、DUR、バリアント、§4 の WAIT 閾値)。
  • CSS: assets/motion-ssot-starter/globals-tokens.css の内容を、アプリのグローバルスタイルシートにある :root と @layer utilities ブロックに統合します。.skeleton のシマー、ルート遷移のスケール --dur-vt-*、グローバルな * ルールでは適用できない 2 つのモーション低減ガードが含まれています。
  • 待機: assets/motion-ssot-starter/use-delayed-flag.ts → 例:lib/use-delayed-flag.ts。アプリ内のすべてのローダーはこれを経由します(§4.2)。これにより、ちらつき防止の組み合わせを共有プリミティブとして管理でき、開発者がそれぞれ誤って再実装することを防げます。

リポジトリに同等のものがすでにある場合は、それを使用してください。並行する仕組みを作らないでください。node scripts/motion-lint.mjs(§7)を実行することで、SSoT を単に宣言するだけでなく、常に 1 つに保てます。

コメント