エージェント用スキル
ui-motion-principles は、Claude Code などのコーディングエージェントが twelve-principles を正しく使えるようにするための Agent Skill です。
「このボタンに気持ちいい押し心地を付けて」「保存したらお祝い演出を」と頼むだけで、エージェントが状況に合う原則と API を選び、既定のガードレールを守って実装し、最後にブラウザで動きを数値確認します。
ここで配布しているファイルは、ライブラリのリポジトリにある skills/ui-motion-principles/ をビルド時にそのまま読み込んだものです。ライブラリと常に同じ版になります。
いつ発動するか
Section titled “いつ発動するか”エージェントは Skill を名前ではなく description で選びます。次の説明文に書いた言い回しや作業内容が依頼に含まれると読み込まれます。
UI コンポーネントに「動き」を付けるときに使う Skill。Disney の 12 原則を実装した TypeScript ライブラリtwelve-principles で、ボタンの押し心地・ホバー・入場/退場・トースト/モーダルの出し入れ・リストの順次表示・エラーの shake・お祝い演出・派手なアニメーションを実装する。「アニメーションを付けて」「動きをつけて」「モーション」「気持ちいい押し心地」「ぬるっと出して」「フェードイン」「バウンス」「派手に」「お祝い演出」「12原則」「twelve-principles を使って」などで発動。React / 素の DOM / Astro アイランドのどれでも使える。インストール
Section titled “インストール”-
上のボタンから
ui-motion-principles.zipをダウンロードします。ターミナルからなら次の 1 行です。ターミナルウィンドウ curl -fsSLO https://twelve-principles.iru-yo.com/skill/ui-motion-principles.zip -
スキル置き場に展開します。プロジェクト単位なら
.claude/skills/、全プロジェクト共通なら~/.claude/skills/です。ターミナルウィンドウ unzip ui-motion-principles.zip -d .claude/skills/ -
エージェントを再起動(または新しいセッションを開始)すると、スキル一覧に
ui-motion-principlesが現れます。
npm パッケージにはスキルが同梱されています(package.json の files に skills を含む)。
cp -r node_modules/twelve-principles/skills/ui-motion-principles .claude/skills/ライブラリのリポジトリでは、コピー用のスクリプトを使えます。既存の同名スキルは置き換えられます。
npm run skill:install -- <スキル置き場のパス># 例: npm run skill:install -- ../my-app/.claude/skillsディレクトリui-motion-principles/
- SKILL.md 手順・決定表・数値の目安・落とし穴
ディレクトリreferences/
- api-cheatsheet.md 主要 API と既定値の早見表
ディレクトリtemplates/
- MotionButton.tsx press + hover + クリックで jump
- Toast.tsx Presence で出し入れするトースト
- CascadeList.tsx 順次表示するリスト
- Celebrate.tsx cartoon personality のお祝い演出
ディレクトリscripts/
- sample-motion.js 再生中の transform/opacity を記録する検証スニペット
SKILL.md がエージェントに渡す本体で、次の流れを指示します。
- 依頼を UI の状況に分類し、決定表から API を選ぶ(レシピ → ビヘイビア → 原則関数の順に既存部品を探す)。
- アプリ全体の personality を 1 つ決める。派手な演出面だけ
bouncy/cartoon(guardrails: false)を使う。 templates/を雛形に実装し、必要ならanticipate→followThrough→exaggerateを重ねる。scripts/sample-motion.jsでブラウザ上の動きを数値で確かめ、reduced motion と静止後のインラインスタイルも確認する。
テンプレートはリポジトリの型チェック(npm run typecheck)の対象なので、ライブラリの API 変更で壊れればビルド前に分かります。
---name: ui-motion-principlesdescription: | UI コンポーネントに「動き」を付けるときに使う Skill。Disney の 12 原則を実装した TypeScript ライブラリ twelve-principles で、ボタンの押し心地・ホバー・入場/退場・トースト/モーダルの 出し入れ・リストの順次表示・エラーの shake・お祝い演出・派手なアニメーションを実装する。 「アニメーションを付けて」「動きをつけて」「モーション」「気持ちいい押し心地」「ぬるっと出して」「フェードイン」 「バウンス」「派手に」「お祝い演出」「12原則」「twelve-principles を使って」などで発動。 React / 素の DOM / Astro アイランドのどれでも使える。---
# ui-motion-principles — 12 原則ライブラリで UI に動きを付ける
## いつ使うか
- Web UI(React / Vanilla TS / Astro island)に、トランジション・マイクロインタラクション・演出を付けるとき。- 既存の CSS transition / framer-motion 的な実装を「原則に沿った」動きに置き換えるとき。- 派手な演出(オンボーディング、成功時のお祝い、ゲーム的 UI、LP)を作るとき。
動きの品質レビュー(監査)が目的なら監査用のスキルを、実装は本スキルを使う。
## 前提: ライブラリの導入
- 解説サイト: https://twelve-principles.iru-yo.com (全 API・ライブデモ・原則ごとの解説)。API 全量は `/reference/api/`。- 導入は npm から:
```shnpm i twelve-principles```
- パッケージには本スキルも同梱される: `cp -r node_modules/twelve-principles/skills/ui-motion-principles .claude/skills/`。- ローカルのリポジトリを試すときは `npm pack` した tarball を入れる(ディレクトリ直指定の symlink だとライブラリ側の `node_modules/react` を掴み React が二重化する)。- import: コア `twelve-principles`、React `twelve-principles/react`(`react >= 18` が peer)。ランタイム依存ゼロ、WAAPI + `commitStyles`(Chrome 84+ / Firefox 75+ / Safari 13.1+)。
## 手順
1. **状況を分類し、API を選ぶ**(下の決定表)。自前でキーフレームを書く前に、レシピ → ビヘイビア → 原則関数の順で既存部品を探す。2. **personality を 1 つ決める**(原則 12: Appeal)。アプリ全体を `MotionProvider` で包む。 - 通常 UI: `natural`(既定)/ `snappy`(業務系)/ `calm`(落ち着き)/ `playful`(楽しい)— `guardrails: true`。 - 派手な面: `bouncy` / `cartoon` — `guardrails: false`(押下の縮み最大 20%、大きな弾み)。 - ブランド独自: `definePersonality({ ...overrides }, base)`。**モジュールスコープの定数**にする。3. **実装する**(パターンは下記、雛形は `templates/`)。4. **合成で質を上げる**: `anticipate`(溜め)→ `followThrough`(行き過ぎて戻る)→ `exaggerate`(振れ幅)を必要な分だけ重ねる。5. **実機で検証する**(必須)。ブラウザでボタンを押し、再生中の `getComputedStyle(el).transform` を rAF でサンプリングして 「溜め → 本動作 → オーバーシュート → 静止」の数値を確認する。スクリプトは `scripts/sample-motion.js`。 reduced motion(`reducedMotion="fade"`)と、静止後にインラインスタイルが消えることも確認する。
## 決定表
| 状況 | 使う API | 主な原則 || --- | --- | --- || ボタン押下の手応え | `<Motion press>` / `usePress` / `pressable` | 1, 5 || ホバーで浮く | `<Motion hover>` / `useHover` / `hoverable` | 11, 6 || カードが視線に傾く | `<Motion tilt>` / `useTilt` / `tiltable` | 11 || マウント時の入場 | `<Motion enter="rise">` / `useEnter` / `play(el, enter(kind))` | 6, 9 || トースト・ポップオーバー・モーダルの出し入れ | `<Presence show enter exit>` / `usePresence`(退場完了後にアンマウント) | 6, 2 || リスト/グリッドの順次表示 | `useCascade(input, { each, cap, drag, trigger })` / `overlap` | 5, 9 || 主役に注目させる(モーダル、選択カード) | `useStage(active)` / `stage(el)` | 3 || 入力エラー | `shake()` | 5 || 通知・注意喚起 | `jump()` / `heartbeat()` / `tada()` / `accompany(primary, "wiggle")` | 2, 8 || 成功・お祝い・オンボーディング | `bounceIn` / `fallIn` / `tada` / `rubberBand` / `flip` + `bouncy`/`cartoon` | 1, 5, 10 || A 点から B 点へ移動 | `arc(from, to, { bend, orient })` + `travelDuration(distance)` | 7, 9 || トグル状態の往復(開閉など) | `useMotion().animateTo(pose)` / `setLayer`(割り込みでも飛ばない) | 6 || 物理シミュレーション(落下・揺れ) | `straightAhead((t, ms) => pose, { duration })` | 4a || 独自のキーポーズ | `poseToPose([...frames], { duration })` | 4b |
コンテキストメニューは入場アニメーションを付けない(退場のみ)。進捗バー以外で `linear` を使わない。
## 実装パターン
```tsx// アプリ全体<MotionProvider personality="playful">{children}</MotionProvider>
// 宣言的に<Motion as="button" press hover enter="pop" onClick={save}>保存</Motion><Presence show={open} enter="pop" exit="pop" role="status">保存しました</Presence>
// イベント駆動(personality はコールバックで受け取る)const { ref, play } = useMotion<HTMLDivElement>();<div ref={ref} onClick={() => play((p) => jump({ personality: p }))} />
// 複数フックの ref を束ねるときは必ず useMemoconst merged = useMemo(() => mergeRefs(ref, pressRef, hoverRef), [ref, pressRef, hoverRef]);```
```ts// Vanillaconst off = pressable(button, { personality: "snappy" }); // 解除関数を返すplay(card, followThrough(anticipate(enter("rise")), { bounce: 0.35 }));```
## 数値の目安(既定値に組み込み済み)
- ユーザー起点の動きは 300ms 以内(`USER_INITIATED_MAX_MS`)。押下 100ms、ホバー 200ms、入場 200ms、退場 150ms。- スタッガーは 1 項目 50ms 以内(`MAX_STAGGER_MS`、`cap` で変更可)。派手なカスケードは `cap: 120〜150`。- 押下の縮みは 2〜5%(guardrails 時)。squash/stretch は UI で ±5%、キャラクター的演出で 20% 以上。- 入場 `easings.out`、退場 `easings.in`、画面内移動 `easings.inOut`、オーバーシュートは bezier ではなく `spring`。
## 落とし穴 (Pitfalls)
- **ライブラリが所有するプロパティ**: アニメーション対象要素の `transform` `transform-origin` `opacity` `filter` `box-shadow`。 同じ要素に CSS transition やレイアウト用 transform を併用しない。必要なら外側ラッパーでレイアウト、内側で動きを分ける。 `elevation` を含むモーション中は CSS の `box-shadow` が上書きされる。- **1 要素 1 モーション**: 同じ要素で `play` すると前のモーションは置き換わる。同時に別の動き(例: 本体の jump と アイコンの wiggle)は別要素に付ける。ホバー + 押下 + 傾きの共存は `setLayer`(ビヘイビアは自動で使う)。- **`mergeRefs` を毎レンダー作らない**(`useMemo`)。作るとアタッチ/デタッチが毎回走る。- **`Presence` はラッパー要素(`as`、既定 `div`)を描画する**。レイアウトに影響する場合は `as` を合わせる。- **`useCascade` は直下の子要素だけ**を動かす。再生し直すには `trigger` を変える。- **`exit(kind)` は `enter(kind)` の開始姿勢へ戻る**(`exit("slideLeft")` は右へ去る)。去る方向を指定したいときは `play(el, { duration, frames: [{ x: 0, easing: easings.in }, { x: -24, opacity: 0 }] })` のように直接書く。- **短い区間の `followThrough` は行き過ぎが小さい**。長距離移動で大きく弾ませたいなら `arc(..., { easing: spring({ bounce }) })` のように spring を easing に渡す。- **SSR/Astro**: `useEnter` や `Presence` の初回入場はハイドレーション後に再生される。Astro では `client:visible` 島にする。- **Expressive レシピは 300ms を超える**。ホバーや押下など日常のフィードバックには使わない。無限ループは画面外で止める (`el.getAnimations({ subtree: true }).forEach(a => a.pause())` を IntersectionObserver で)。- **reduced motion**: 既定 `"auto"` は OS 設定を尊重し空間移動をフェードに置換する。`"full"` は本質的な動きに限る。- **personality オブジェクトを JSX 内で生成しない**(毎レンダー別物になり、ビヘイビアが付け直される)。
## 運用方針
- 動きは「控えめな UI 既定値」を基本に、演出面(お祝い・オンボーディング等)だけ意図的に派手にする。- 実装後は必ずブラウザで動作を数値確認してから報告する(ユニットテストだけで済ませない)。
## サポートファイル
- `references/api-cheatsheet.md` — 主要 API のシグネチャと既定値の早見表- `templates/MotionButton.tsx` — press + hover + クリックで jump するボタン- `templates/Toast.tsx` — Presence で出し入れするトースト(自動クローズ付き)- `templates/CascadeList.tsx` — 順次表示するリスト(cap/drag 付き)- `templates/Celebrate.tsx` — 成功時に cartoon personality で派手に祝うコンポーネント- `scripts/sample-motion.js` — ブラウザで再生中の transform/opacity をサンプリングする検証スニペット# twelve-principles API 早見表
正はリポジトリの `src/index.ts` と `src/react/index.ts`(docs の API リファレンス)。ここは要点だけ。
## モデル
- `Pose`: `x y z`(px) `scale scaleX scaleY` `rotate rotateX rotateY skewX skewY`(deg) `opacity` `blur`(px) `elevation`(影の高さ)。静止状態 `REST` からの**絶対値**。- `PoseFrame = Pose & { offset?: 0..1; easing?: string | (t) => number }` — easing はそのフレームから次までの区間に効く。- `MotionSpec = { frames, duration(ms), delay?, easing?(全体), iterations?, direction?, origin?, perspective? }`。- `compile(spec)` → WAAPI 引数。JS イージング(spring 等)は 60fps 相当で焼き込み。
## 再生(DOM)
| API | 要点 || --- | --- || `play(el, spec, { reducedMotion?, persist? })` | `Animation` を返す。同一要素の前のモーションは置換。静止で終わればインラインスタイルを消す || `animateTo(el, pose, { transition })` | 現在姿勢から割り込み遷移。`transition` は `{ duration, easing }` か `spring(...)` || `setLayer(el, name, pose \| null, opts)` | 名前付きレイヤー合成(移動・回転・高さは加算、拡大・不透明度は乗算) || `currentPose(el)` / `stop(el)` / `playAll(items)` | 再生中の姿勢 / その場で停止 / まとめて再生 || `pressable` `hoverable` `tiltable` | ビヘイビア。解除関数を返す || `stage(el, opts)` → `{ release() }` | 主役を前へ、周囲を暗く・ぼかし・後退 |
## 原則関数(MotionSpec → MotionSpec など)
| # | API | 既定値など || --- | --- | --- || 1 | `deform(amount, axis)` `squash` `stretch` `squashStretch({ intensity=0.05, axis="y", duration=300, origin })` | 体積保存 scaleX×scaleY=1 || 2 | `anticipate(spec, { amount=0.15, share=0.3, fit="compress" })` `windUp` | fit `"extend"` で本動作の尺を保つ || 3 | `stagingPoses({ dim=0.5, blur=2, recede=0.02, lift=12 })` | || 4 | `straightAhead((t, ms) => pose, { duration, fps=60 })` `poseToPose(frames, { duration=300 })` | poseToPose の区間既定は inOut || 5 | `followThrough(spec, { bounce=0.3 })` `overlap(spec, n, { drag=0.1, each=30, cap=50, total=300, from })` `spring({ duration=400, bounce=0.2 } \| { stiffness, damping, mass })` | spring は `.duration` に静定時間 || 6 | `easings.{linear,inOut,out,in,anticipate,overshoot}` `cubicBezier` | || 7 | `arc(from, to, { bend=0.2, orient, samples=24, duration, easing=inOut })` | bend 正で上に膨らむ || 8 | `secondaryAction("wiggle"\|"pulse"\|"float"\|"sway", opts)` `accompany(primary, kind, { attenuation=0.6, lag=0.15 })` | || 9 | `durations{instant 100, fast 150, base 200, slow 300, deliberate 500}` `duration(token, tempo)` `travelDuration(px)` `staggerDelays(n, opts)` | || 10 | `exaggerate(poseOrSpec, factor, { rest, keys })` | opacity は対象外 || 11 | `lift(level=4)` `tiltToward(point, max=8)` `solid(spec, perspective=800)` `shadowForElevation(e)` | || 12 | `personalities` `definePersonality(overrides, base)` `resolvePersonality` | 下表 |
## Personality
| name | tempo | exaggeration | bounce | anticipation | squash | guardrails || --- | --- | --- | --- | --- | --- | --- || natural | 1 | 1 | 0.2 | 0.15 | 0.04 | true || snappy | 0.8 | 0.9 | 0.1 | 0.08 | 0.03 | true || calm | 1.25 | 0.7 | 0 | 0 | 0.02 | true || playful | 1 | 1.4 | 0.45 | 0.25 | 0.08 | true || bouncy | 0.95 | 1.7 | 0.55 | 0.3 | 0.12 | false || cartoon | 1.15 | 2.4 | 0.65 | 0.45 | 0.22 | false |
`definePersonality` の範囲: tempo 0.1–4, exaggeration 0–4, bounce 0–0.95, anticipation 0–1, squash 0–0.5, guardrails boolean。
## レシピ(`RecipeOptions = { personality, duration, distance }`)
- UI: `enter(kind="rise")` `exit(kind="fade")`(kind: fade rise drop slideLeft slideRight zoom pop)`jump({ height=16 })` `shake({ distance=6 })` `pressDepth(p)` `settleSpring(p)`- Expressive: `rubberBand` `jello` `tada` `heartbeat` `swing` `wobble({ distance=25 })` `flip` `bounceIn` `fallIn({ height=80 })`、名前引き `expressive[name](opts)`
## React(`twelve-principles/react`)
- `MotionProvider({ personality, reducedMotion })` `useMotionConfig()`- `usePress(opts|false)` `useHover` `useTilt` → callback ref- `useEnter(kind|spec|(p)=>spec)` `usePresence(show, { enter, exit, initial })` → `{ present, ref }`- `useMotion()` → `{ ref, play(specOrBuilder), animateTo(pose), stop() }`- `useStage(active, opts)` `useCascade(input, { trigger, each, cap, drag, from })`- `<Motion as press hover tilt enter>` `<Presence show enter exit initial as>` `mergeRefs(...)`// Paste into the browser console, or pass to a browser tool's evaluate().// Clicks a trigger, then samples the target's animated styles on animation frames,// so you can confirm wind-up → action → overshoot → rest with real numbers.//// sampleMotion({ trigger: "button.save", target: ".card", times: [30, 120, 250, 600] })// → [{ t: 31, transform: "matrix(...)", opacity: "1" }, ..., { t: "after", inline: "" }]//// Checks worth making:// - Anticipation: translate goes the opposite way first.// - Follow through: value passes the end value, then settles on it.// - Rest: after the motion, inline style is empty (the library hands the element back to CSS)// unless the end pose is not the rest pose (then transform/opacity stay committed).// - Reduced motion: repeat with reducedMotion="fade" and expect an immediate jump + short fade.async function sampleMotion({ trigger, target, times = [40, 120, 250, 600], settleMs = 1500 }) { const button = typeof trigger === "string" ? document.querySelector(trigger) : trigger; const element = typeof target === "string" ? document.querySelector(target) : target; if (!button || !element) throw new Error("sampleMotion: trigger or target not found"); button.click(); const start = performance.now(); const samples = []; for (const t of times) { while (performance.now() - start < t) await new Promise((r) => requestAnimationFrame(r)); const style = getComputedStyle(element); samples.push({ t: Math.round(performance.now() - start), transform: style.transform, opacity: style.opacity }); } await new Promise((r) => setTimeout(r, settleMs)); samples.push({ t: "after", inline: element.getAttribute("style") ?? "", running: element.getAnimations().length }); return samples;}// List whose items enter one after another (overlapping action). Trailing items// settle with more follow-through (`drag`). `cap` is the per-item delay limit:// keep the default 50ms for UI lists, raise it (120–150) for showcase sections.import type { ReactNode } from "react";import { enter, type TransitionKind } from "twelve-principles";import { useCascade } from "twelve-principles/react";
export interface CascadeListProps { items: readonly { id: string; content: ReactNode }[]; kind?: TransitionKind; /** Change this value to replay the cascade (e.g. after a filter change). */ replayKey?: unknown; showy?: boolean;}
export function CascadeList({ items, kind = "rise", replayKey, showy = false }: CascadeListProps) { const ref = useCascade<HTMLUListElement>((p) => enter(kind, { personality: p }), { each: showy ? 120 : 30, cap: showy ? 150 : undefined, total: showy ? 900 : 300, drag: showy ? 0.15 : 0.05, trigger: replayKey, }); return ( <ul ref={ref}> {items.map((item) => ( <li key={item.id}>{item.content}</li> ))} </ul> );}// Success moment: the badge falls in under gravity and lands with a squash,// then does a victory `tada`. Scoped to the `cartoon` personality (guardrails// off) without changing the rest of the app.import { useEffect } from "react";import { fallIn, tada } from "twelve-principles";import { MotionProvider, useMotion } from "twelve-principles/react";
function Badge({ label }: { label: string }) { const { ref, play } = useMotion<HTMLDivElement>(); useEffect(() => { const landing = play((p) => fallIn({ personality: p, height: 120 })); let cancelled = false; landing?.finished.then( () => { if (!cancelled) play((p) => tada({ personality: p })); }, () => undefined, // interrupted: skip the follow-up ); return () => { cancelled = true; }; }, [play]); return ( <div ref={ref} className="celebrate-badge" role="status"> {label} </div> );}
export function Celebrate({ label = "完了!" }: { label?: string }) { return ( <MotionProvider personality="cartoon"> <Badge label={label} /> </MotionProvider> );}// Button with physical press (squash + spring release), hover lift, and an// optional attention jump on click. Personality comes from the nearest MotionProvider.import { useMemo, type ButtonHTMLAttributes } from "react";import { jump } from "twelve-principles";import { mergeRefs, useHover, useMotion, usePress } from "twelve-principles/react";
export interface MotionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { /** Play a small jump (anticipation + squash & stretch) after each click. */ bounceOnClick?: boolean;}
export function MotionButton({ bounceOnClick = false, onClick, ...rest }: MotionButtonProps) { const motion = useMotion<HTMLButtonElement>(); const press = usePress<HTMLButtonElement>(); const hover = useHover<HTMLButtonElement>({ level: 4 }); // Stable merged ref: an inline mergeRefs() would detach/attach the behaviours every render. const ref = useMemo(() => mergeRefs(motion.ref, press, hover), [motion.ref, press, hover]); return ( <button {...rest} ref={ref} onClick={(event) => { onClick?.(event); if (bounceOnClick) motion.play((p) => jump({ personality: p, height: 8 })); }} /> );}// Toast that enters with ease-out ("pop" overshoots on a spring), exits with// ease-in (anticipation swell for playful personalities) and unmounts only after// the exit finishes. Re-showing mid-exit reverses smoothly.import { useEffect, type ReactNode } from "react";import { Presence } from "twelve-principles/react";
export interface ToastProps { open: boolean; onClose: () => void; /** Auto-close delay in ms. 0 disables. */ autoCloseMs?: number; children: ReactNode;}
export function Toast({ open, onClose, autoCloseMs = 3000, children }: ToastProps) { useEffect(() => { if (!open || autoCloseMs === 0) return; const id = setTimeout(onClose, autoCloseMs); return () => clearTimeout(id); }, [open, autoCloseMs, onClose]);
return ( <Presence show={open} enter="pop" exit="pop" role="status" aria-live="polite" className="toast"> {children} </Presence> );}- クイックスタート — スキルが前提にしている導入手順
- UI ガイドライン — スキルが守らせる数値の出典
- 派手な動き —
guardrailsを外す場面 - API リファレンス