React Animation
インストール版プラグインには docs/react/ がありません — このファイルの docs/... パスを開けない場合は https://raw.githubusercontent.com/joo6077/claude-plugins/main/ の後ろに同じパスを付けて読み、それでも読めない場合は内容を捏造せずに読めなかったと記載します。
Gotchas
-
ライブラリ0個の原則 — 絶対に例外なし: Motion (framer-motion) / dnd-kit / react-spring / react-transition-group / @formkit/auto-animate / react-dnd / gsap / lottie-react / react-beautiful-dnd / animate.css はインストールおよび import 禁止です。これらの import 文がコードベースに存在すると
/react-auditがビルド失敗を発行します。ユーザーが要求しても代替実装を提案し、ライブラリの使用を拒否します。 -
最も低い Tier から試行: 「animation」リクエストを受け取ったら、無条件にTier 3から使うミスが頻発します。判定ルールに従い、まず Tier 1 で解決可能か確認し、不可能な場合のみ上位のTierに進みます。
-
tailwindcss-animateプラグインの確認:animate-in、fade-in-*、slide-in-from-*のような拡張ユーティリティはtailwindcss-animateが必要です。shadcn をインストールしたプロジェクトにはデフォルトで含まれますが、ない場合はpnpm add -D tailwindcss-animate後、tailwind.config.tsにプラグイン登録を案内します。 -
view-transition-nameの一意性: 同じ名前を2つの要素が同時に持つと「duplicate name」エラーが発生します。必ず動的 ID (view-transition-name: task-${item.id}) を使用して一意性を保証します。 -
Pointer cancel 処理必須:
pointercancelイベントを処理しないと、タブ切り替え、他のアプリへのフォーカス、要素の unmount 時にドラッグ状態がリークします。onPointerCancelハンドラーで必ず FSM をidleにリセットします。 -
touch-action: noneを忘れるとモバイルドラッグ不可: pointer event ベースのドラッグ要素に Tailwind のtouch-noneクラスを必ず追加し、ブラウザのデフォルトスクロール/ズームをブロックします。 -
Zustand drag store はグローバル単一インスタンス: 機能ごとに個別の drag store を作成しません。
src/presentation/shared/stores/drag-store.tsの1つをすべての機能が共有します。 -
prefers-reduced-motion未適用: すべてのTierで@media (prefers-reduced-motion: reduce)または Tailwind のmotion-reduce:*バリアントを使用してアニメーションを無効化する必要があります。/react-auditが未適用時に警告を発行します。 -
アニメーションは presentation 専用:
domain/、data/レイヤーに pointer event / view transition / Zustand drag store を import しません。アニメーション関連コードはsrc/presentation/にのみ配置します。 -
Firefox の types パラメータ未サポート:
document.startViewTransition({ update, types: [...] })のtypesオプションは2026年時点で Chromium 系でのみ安定してサポートされています。withViewTransitionラッパーで try/catch によるフォールバックを処理します。 -
scroll-driven animation のブラウザサポート (2026-08-13 更新):
animation-timeline: scroll()/scroll-timelineは Can I Use 基準グローバル 85.43% — Chrome/Edge 115+、Safari/iOS 26+、Firefox 156+ です。Firefox がフラグの背後にあるという記述は古いです。 ただし、MDN 基準ではview()はまだ not Baseline であるため、@supports (animation-timeline: scroll())/@supports (animation-timeline: view())でラップし、非サポート時は静的スタイルでフォールバックする方針はそのまま維持します。 -
view-transition-name: match-elementの活用 (Chrome 137+): 数十の要素に手動で一意な名前を付ける代わりに、view-transition-name: match-elementCSS 値を使用するとブラウザが自動的に要素をマッチングします。ただし、2026-Q2 時点では Chromium 専用のため、フォールバックパスを維持します。 -
モーションは静的コードでは証明されない — 完了宣言前の証拠必須 (E2): このスキルの成果物は再生されて初めて存在を確認できます。
animate-*クラスが付いている事実、コードがstartViewTransitionを呼んでいる事実は R3(静的) 証拠であり、アニメーションが実際に再生されたという証拠ではありません — keyframe 未定義、prefers-reduced-motion常時ヒット、親のoverflow: hiddenクリッピング、非サポートブラウザのフォールバック突入は、すべて静かに「何も起こらない」で終わります。完了直前にreact-kit/references/render-evidence-protocol.md§4 のチェックリストを埋めます。スナップショット比較で検証する場合は、前/後の2時点を指定します — 静止フレーム1枚はどんなモーションでも同じ結果を返すため oracle ではありません (§3 d)。証拠が得られない場合は[未検証]を付け、4つの欄 (妨げたもの · 試みた回避策 · 制御不能な理由 · 再検証命令 — 規約 §2) を埋めて部分完了として報告します。証拠確保のためにアニメーションライブラリを導入しません。 Library Policy (Gotcha #1) はこの規約よりも上位であり、いかなる検証の便宜も緩和されません。
-
基準キャプチャは編集前に撮る —
react-kit/references/render-evidence-protocol.md§1 Step 0 と §2 比較反復順序の1番を最初の編集前に実行し、その結果 (言い換え · 慣例表 · 現在の姿のキャプチャパスと変更されるべき標識) を応答に残します。編集後は編集前の姿を再び撮ることはできません。 -
Tier 2 は更新の種類で区別する — React 19.3 の
<ViewTransition>は安定API です (2026-09-26 追加):reactからインポートする<ViewTransition>は 19.3 で実験APIではなく安定APIになりました。ラップされた子が Transition としてマークされた更新 (startTransition内の更新 · Suspense reveal ·useDeferredValue) でスタイルが変更されるか、マウント・アンマウントされるときに動作し、DOM でのみ動作します (出典: https://react.dev/blog/2026/09/09/react-19-3 「We shared it as an experimental API last year, and in 19.3 it's stable and ready to use.」)。したがって、Transition としてマークされた React 状態更新には<ViewTransition>を使用し、それ以外の DOM 更新 (Transition ではない同期更新 · React 外の DOM 操作) には §3.1 のwithViewTransitionラッパーをそのまま使用します。ラッパーは削除しません。react パッケージ内のコンポーネントであるため、ライブラリ0個の原則 (#1) には抵触しません。プロジェクトのreactバージョンが 19.3 未満の場合はラッパーのみを使用します。
プロセス
1. 自動ティア判定
--tier フラグがない場合、ユーザーリクエストを分析して最も低いティアを選択します。
| キーワード / シナリオ | 判定 Tier |
|---|---|
| 「fade in」「slide up」「scale」「pulse」「bounce」「hover 効果」「opacity」「shimmer」「エントリーアニメーション」 | T1 |
| 「skeleton ローディング → 完了遷移」「ボタン hover」「accordion」「モーダル open/close」「状態変化」 | T1 |
| 「スクロールアニメーション」「scroll-driven」「parallax」「スクロール進行バー」「スクロールベース」 | T1 |
| 「ページ遷移」「shared element」「grid to board」「ビュー遷移」「ルート遷移」「DOM構造変更」 | T2 |
| 「scroll-triggered」「スクロール交差時トリガー」「特定位置からアニメーション開始」 | T2 |
| 「ドラッグアンドドロップ」「並び替えリスト」「sortable」「kanban」「ドラッグ」「ドロップ」「gesture」「矢印接続線」 | T3 |
T2/T3 の境界が曖昧な場合は animation-architect-react エージェントに相談します。
3-Tier 概要:
| Tier | ツール | 適用シナリオ | 難易度 |
|---|---|---|---|
| T1 | Tailwind animate-* + CSS @keyframes + scroll-driven | 状態変化、hover、単純ループ、スクロール連動 | 低 |
| T2 | View Transitions API (withViewTransition ラッパー) · React 19.3 <ViewTransition> (Transition としてマークされた更新のみ — Gotcha 15) | ビュー/ルート遷移、shared element、FLIP | 中 |
| T3 | Pointer Events + FSM + requestAnimationFrame | ドラッグアンドドロップ、ジェスチャー、SVG 接続線 | 高 |
2. Tier 1 — Tailwind + CSS 実装
2.1 Tailwind 組み込みユーティリティ
| カテゴリ | ユーティリティ | 例 |
|---|---|---|
| Transition | transition-transform、transition-colors、transition-opacity | hover:scale-105 transition-transform duration-200 |
| Duration | duration-75~duration-1000 | duration-300 |
| Easing | ease-linear、ease-in、ease-out、ease-in-out | ease-in-out |
| Animation (組み込み) | animate-spin、animate-ping、animate-pulse、animate-bounce | ローディング、通知 |
| Delay | delay-75~delay-1000 | stagger 効果 |
2.2 tailwindcss-animate 拡張ユーティリティ
tailwindcss-animate プラグインが提供する enter/exit アニメーション:
animate-in/animate-outfade-in-*、fade-out-*slide-in-from-top/bottom/left/right-*、slide-out-to-*zoom-in-*、zoom-out-*
data-[state=...] 属性セレクターと組み合わせて状態ベースのアニメーションを実装します:
// ドロワー open/close の例
<div
data-state={open ? 'open' : 'closed'}
className="
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left
duration-300
motion-reduce:transition-none motion-reduce:animate-none
"
>
ドロワー内容
</div>
2.3 CSS @keyframes の直接宣言
Tailwind ユーティリティでカバーできないカスタムアニメーションは globals.css の @theme ブロックに宣言します:
/* src/presentation/styles/globals.css */
@import "tailwindcss";
@theme {
--animate-wiggle: wiggle 1s ease-in-out infinite;
--animate-shimmer: shimmer 2s linear infinite;
@keyframes wiggle {
0%, 100% { transform: rotate(-3deg); }
50% { transform: rotate(3deg); }
}
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
使用: <div className="animate-wiggle">...</div>
2.4 Tier 1 シナリオ例
Fade-in エントリー:
<div className="animate-in fade-in-0 duration-500 motion-reduce:animate-none">
ページロード時にフェードイン
</div>
Hover scale ボタン:
<button className="transition-transform duration-200 hover:scale-105 active:scale-95 motion-reduce:transition-none">
ボタン
</button>
スタッガーリストエントリー:
{items.map((item, i) => (
<div
key={item.id}
className="animate-in slide-in-from-bottom-4 fade-in-0 motion-reduce:animate-none"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: 'backwards' }}
>
{item.name}
</div>
))}
2.5 Scroll-driven Animations (CSS ネイティブ)
スクロール位置に連動するアニメーションを JS なしで CSS animation-timeline で実装します。2026-08-13 時点で scroll() / scroll-timeline は Chrome/Edge 115+、Safari/iOS 26+、Firefox 156+ でサポートされており (Can I Use グローバル 85.43%)、view() は MDN 基準でまだ not Baseline です。どちらの場合も @supports でラップして非サポートブラウザで graceful fallback します。
スクロール進行バー:
/* globals.css */
@keyframes progress-grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
<div
className="fixed top-0 left-0 h-1 w-full origin-left bg-primary motion-reduce:hidden"
style={{
animation: 'progress-grow linear',
animationTimeline: 'scroll(root block)',
}}
/>
要素エントリー時の fade-in (view timeline):
@keyframes fade-slide-in {
from { opacity: 0; transform: translateY(2rem); }
to { opacity: 1; transform: translateY(0); }
}
@supports (animation-timeline: view()) {
.scroll-reveal {
animation: fade-slide-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
<section className="scroll-reveal motion-reduce:opacity-100">
スクロール時に出現するコンテンツ
</section>
注意: scroll-triggered animations (Chrome 145, 2026) は、特定のスクロールオフセット交差時に時間ベースのアニメーションをトリガーする別個の概念です。scroll-driven と混同しないでください。scroll-triggered は Tier 2 の候補として分類します。
3. Tier 2 — View Transitions API 実装
same-document View Transitions は MDN 基準 Baseline 2025、Can I Use 基準グローバル 90.2% — Chrome/Edge 111+、Safari 18+、Firefox 144+ (2026-08-13 更新)。SPA 内の DOM 変更時に自動 FLIP アニメーション。
一方、@view-transition ベースの cross-document / MPA 遷移といくつかの詳細機能はまだ limited availability です。ルート遷移に使用する場合は same-document パスをデフォルトとし、feature detection + fallback を維持します (§6 標準カバレッジギャップ番号8)。
3.1 withViewTransition ラッパー作成
ファイルがない場合は作成します:
// src/presentation/shared/lib/view-transition.ts
export function withViewTransition(
updateDOM: () => void,
options?: { types?: string[] },
): void {
// prefers-reduced-motion 尊重
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
updateDOM()
return
}
// Feature detection (旧ブラウザ fallback)
if (typeof document.startViewTransition !== 'function') {
updateDOM()
return
}
// types パラメータサポート有無の分岐 (Firefox 初期実装は非サポート)
try {
if (options?.types && options.types.length > 0) {
document.startViewTransition({ update: updateDOM, types: options.types })
} else {
document.startViewTransition(updateDOM)
}
} catch {
updateDOM()
}
}
3.2 view-transition-name CSS 共有要素
DOM 構造が変わっても「同じ要素」として識別し、スムーズに移動させます:
// 動的 ID で一意性を保証 (重複名禁止)
<div
className="rounded-lg border p-4"
style={{ viewTransitionName: `task-${task.id}` }}
>
{task.title}
</div>
3.3 グリッド ↔ ボードビュー遷移 (代表シナリオ)
// src/presentation/features/tasks/components/task-switcher.tsx
import { withViewTransition } from '@/presentation/shared/lib/view-transition'
import { useTaskStore } from '../store'
export function TaskViewSwitcher() {
const viewMode = useTaskStore((s) => s.viewMode)
const setViewMode = useTaskStore((s) => s.setViewMode)
return (
<button
onClick={() => {
withViewTransition(() => {
setViewMode(viewMode === 'grid' ? 'board' : 'grid')
})
}}
>
{viewMode === 'grid' ? 'ボードビューへ' : 'グリッドビューへ'}
</button>
)
}
export function TaskList({ tasks }: { tasks: Task[] }) {
const viewMode = useTaskStore((s) => s.viewMode)
return (
<div className={viewMode === 'grid' ? 'grid grid-cols-3 gap-4' : 'flex gap-4 overflow-x-auto'}>
{tasks.map((task) => (
<div
key={task.id}
className="rounded-lg border p-4"
style={{ viewTransitionName: `task-${task.id}` }}
>
{task.title}
</div>
))}
</div>
)
}
3.4 CSS 遷移の詳細調整
/* globals.css — 遷移 duration + easing */
::view-transition-group(*) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* 特定要素の個別調整 */
::view-transition-group(hero-image) {
animation-duration: 0.6s;
}
/* reduced-motion で即時遷移 */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*) {
animation-duration: 0.01ms;
}
}
4. Tier 3 — カスタム Pointer Primitives 実装
ライブラリなしで Pointer Events API + 状態機械(FSM) で直接実装します。
4.1 useDrag フック作成
// src/presentation/shared/hooks/use-drag.ts
import { useCallback, useEffect, useReducer, useRef } from 'react'
// FSM 3状態: idle / dragging / dropping
type DragState =
| { kind: 'idle' }
| { kind: 'dragging'; startX: number; startY: number; offsetX: number; offsetY: number; pointerId: number }
| { kind: 'dropping'; offsetX: number; offsetY: number }
type DragAction =
| { type: 'start'; x: number; y: number; pointerId: number }
| { type: 'move'; x: number; y: number }
| { type: 'drop' }
| { type: 'resolved' }
| { type: 'cancel' }
function dragReducer(state: DragState, action: DragAction): DragState {
switch (action.type) {
case 'start':
return { kind: 'dragging', startX: action.x, startY: action.y, offsetX: 0, offsetY: 0, pointerId: action.pointerId }
case 'move':
if (state.kind !== 'dragging') return state
return { ...state, offsetX: action.x - state.startX, offsetY: action.y - state.startY }
case 'drop':
if (state.kind !== 'dragging') return state
return { kind: 'dropping', offsetX: state.offsetX, offsetY: state.offsetY }
case 'resolved':
case 'cancel':
return { kind: 'idle' }
}
}
type DragHandlers = {
onPointerDown: (e: React.PointerEvent) => void
onPointerMove: (e: React.PointerEvent) => void
onPointerUp: (e: React.PointerEvent) => void
onPointerCancel: (e: React.PointerEvent) => void
}
export function useDrag(): {
state: DragState
handlers: DragHandlers
ref: React.RefObject<HTMLElement | null>
resolve: () => void
} {
const [state, dispatch] = useReducer(dragR
... (以降は省略)