squash · anticipation · arcs · follow-through · secondary
Disney 12 Principles
『The Illusion of Life』の 12 原則のうち UI モーションに直結する 5 つをタブで切り替えて体感する教材。物理法則をどう嘘でリアルに見せるか。
Squash & Stretch
潰しと伸び
重さを伝える。落下時に潰れ、跳ねる時に伸びる
// notes
- Squash & Stretch は落下・着地の瞬間に scaleY / scaleX を逆方向に動かすと「重さ」が出る。
- Anticipation は本番と逆方向に小さく予備動作を入れると、次の動きの予測可能性が上がる。
- Arcs は直線移動が本能的に「機械的」に見える理由を、同じ距離・同じ時間の比較で体感できる。
- Secondary Action はメインの動きを隠れて強化する脇役で、目立たせすぎると逆にノイズになる。
/**
* Disney 12 Principles — UI で特に効く5原則を tab で切り替えて比較する。
* 移植元: motion-playground app/_sections/Disney12Principles.tsx(dark 変換 M-B-2)
* tab(原則の選択)自体がユーザー操作駆動の replay トリガーのため useLabReplay は不要。
*/
import { motion } from 'motion/react';
import { useState } from 'react';
import { easings } from '../../../lib/motion-tokens/playground';
type Key =
| 'squash'
| 'anticipation'
| 'arcs'
| 'follow-through'
| 'secondary-action';
const tabs: { key: Key; label: string; jp: string; note: string }[] = [
{
key: 'squash',
label: 'Squash & Stretch',
jp: '潰しと伸び',
note: '重さを伝える。落下時に潰れ、跳ねる時に伸びる',
},
{
key: 'anticipation',
label: 'Anticipation',
jp: '予備動作',
note: 'ジャンプ前にしゃがむ。観客の視線を準備する',
},
{
key: 'arcs',
label: 'Arcs',
jp: 'アーク',
note: '自然な動きはほぼ全て曲線。直線移動は機械的',
},
{
key: 'follow-through',
label: 'Follow-through',
jp: 'フォロースルー',
note: '停止後も付随物が動く。生命感の出所',
},
{
key: 'secondary-action',
label: 'Secondary Action',
jp: '副次的動作',
note: 'メインの動きを強化する補助モーション',
},
];
function SquashDemo({ playKey }: { playKey: number }) {
return (
<div className="relative h-56 overflow-hidden rounded-md bg-[var(--bg-elevated)]">
<motion.div
key={playKey}
initial={{ y: -120, scaleY: 1.3, scaleX: 0.85 }}
animate={{
y: [-120, 90, 90, 70, 90, 80, 90],
scaleY: [1.3, 0.6, 1, 1, 1, 1, 1],
scaleX: [0.85, 1.3, 1, 1, 1, 1, 1],
}}
transition={{
duration: 1.4,
times: [0, 0.3, 0.4, 0.55, 0.7, 0.85, 1],
ease: easings.md3Standard,
}}
style={{ transformOrigin: 'bottom' }}
className="absolute left-1/2 -translate-x-1/2 h-20 w-20 rounded-full bg-rose-500 shadow-sm"
/>
<div className="absolute inset-x-0 bottom-0 h-1 bg-[var(--border-strong)]" />
</div>
);
}
function AnticipationDemo({ playKey }: { playKey: number }) {
return (
<div className="relative h-56 overflow-hidden rounded-md bg-[var(--bg-elevated)]">
<motion.div
key={playKey}
initial={{ y: 0, scaleY: 1 }}
animate={{
y: [0, 0, -100, 0, 0],
scaleY: [1, 0.7, 1, 1, 1],
}}
transition={{
duration: 1.6,
times: [0, 0.25, 0.55, 0.85, 1],
ease: easings.md3Emphasized,
}}
style={{ transformOrigin: 'bottom' }}
className="absolute left-1/2 -translate-x-1/2 bottom-1 h-20 w-20 rounded-md bg-sky-500"
/>
<div className="absolute inset-x-0 bottom-0 h-1 bg-[var(--border-strong)]" />
</div>
);
}
function ArcsDemo({ playKey }: { playKey: number }) {
return (
<div className="relative h-56 overflow-hidden rounded-md bg-[var(--bg-elevated)]">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-[11px] text-[var(--text-muted)]">
直線
</div>
<motion.div
key={`line-${playKey}`}
initial={{ x: 50, y: 60 }}
animate={{ x: 280, y: 60 }}
transition={{ duration: 1.4, ease: easings.md3Standard }}
className="absolute h-8 w-8 rounded-full bg-[var(--text-muted)]"
/>
<div className="absolute left-4 top-[140px] text-[11px] text-[var(--text-muted)]">
アーク
</div>
<motion.div
key={`arc-${playKey}`}
initial={{ x: 50, y: 150 }}
animate={{ x: 280, y: 150 }}
transition={{ duration: 1.4, ease: easings.md3Standard }}
className="absolute h-8 w-8 rounded-full bg-[var(--success)]"
style={{
offsetPath: "path('M 0 0 Q 115 -60, 230 0')",
}}
/>
</div>
);
}
function FollowThroughDemo({ playKey }: { playKey: number }) {
return (
<div className="relative h-56 overflow-hidden rounded-md bg-[var(--bg-elevated)]">
<motion.div
key={playKey}
initial={{ x: 0 }}
animate={{ x: [0, 240, 240] }}
transition={{
duration: 1.6,
times: [0, 0.55, 1],
ease: easings.md3Standard,
}}
className="absolute top-1/2 left-8 -translate-y-1/2 flex items-center gap-1"
>
<div className="h-10 w-10 rounded-md bg-amber-500" />
<motion.div
animate={{ rotate: [0, 8, -12, 6, -3, 0] }}
transition={{
duration: 1,
delay: 0.55,
times: [0, 0.15, 0.35, 0.55, 0.75, 1],
ease: easings.gentle,
}}
style={{ transformOrigin: 'left center' }}
className="h-2 w-12 rounded-full bg-amber-700"
/>
</motion.div>
</div>
);
}
function SecondaryActionDemo({ playKey }: { playKey: number }) {
return (
<div className="relative h-56 overflow-hidden rounded-md bg-[var(--bg-elevated)]">
<motion.div
key={playKey}
initial={{ y: 0 }}
animate={{ y: [0, -8, 0, -8, 0, -8, 0] }}
transition={{ duration: 1.6, ease: easings.gentle }}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center gap-3"
>
<motion.div
animate={{ rotate: [0, 6, -6, 6, -6, 6, 0] }}
transition={{ duration: 1.6, ease: easings.gentle }}
style={{ transformOrigin: 'top' }}
className="h-12 w-2 rounded-full bg-[var(--text-secondary)]"
/>
<div className="h-12 w-12 rounded-full bg-[var(--text-secondary)]" />
<motion.div
animate={{ rotate: [0, -6, 6, -6, 6, -6, 0] }}
transition={{ duration: 1.6, ease: easings.gentle }}
style={{ transformOrigin: 'top' }}
className="h-12 w-2 rounded-full bg-[var(--text-secondary)]"
/>
</motion.div>
<p className="absolute inset-x-0 bottom-3 text-center text-[11px] text-[var(--text-muted)]">
歩行 = 上下移動(メイン)+ 腕振り(副次的)
</p>
</div>
);
}
export default function Disney12Principles() {
const [active, setActive] = useState<Key>('squash');
const [playKey, setPlayKey] = useState(0);
const current = tabs.find((t) => t.key === active)!;
return (
<div data-lab-island className="w-full p-4 lg:p-6">
<div className="mb-4 flex flex-wrap gap-1.5" role="tablist">
{tabs.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active === t.key}
onClick={() => {
setActive(t.key);
setPlayKey((k) => k + 1);
}}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
active === t.key
? 'bg-[var(--text-primary)] text-[var(--text-inverted)]'
: 'bg-[var(--bg-surface)] text-[var(--text-secondary)] ring-1 ring-[var(--border-subtle)] hover:bg-[var(--bg-elevated)]'
}`}
>
{t.label}
</button>
))}
</div>
<div className="rounded-lg border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-5">
<div className="mb-3 flex items-baseline justify-between">
<div>
<p className="text-sm font-semibold text-[var(--text-primary)]">
{current.label}
</p>
<p className="text-xs text-[var(--text-muted)]">{current.jp}</p>
</div>
</div>
<p className="mb-4 text-sm leading-[1.75] text-[var(--text-secondary)]">
{current.note}
</p>
{active === 'squash' && <SquashDemo playKey={playKey} />}
{active === 'anticipation' && <AnticipationDemo playKey={playKey} />}
{active === 'arcs' && <ArcsDemo playKey={playKey} />}
{active === 'follow-through' && (
<FollowThroughDemo playKey={playKey} />
)}
{active === 'secondary-action' && (
<SecondaryActionDemo playKey={playKey} />
)}
</div>
</div>
);
} Implement 5 of Disney's 12 principles as switchable UI motion demos (motion/react):
- Squash & Stretch: bounce with inverse scaleY / scaleX at impact, 1400ms, cubic-bezier(0.2, 0, 0, 1)
- Anticipation: dip scaleY to 0.7 before jumping to y -100, 1600ms, cubic-bezier(0.05, 0.7, 0.1, 1)
- Arcs: same travel as straight translate vs SVG offset-path curve — the curve reads organic, the line mechanical
- Follow-through: rigid body moves 1600ms, attached tail rotates [0, 8, -12, 6, -3, 0]deg over 1000ms starting 550ms late
- Secondary Action: body bobs while limbs swing opposite phase, reinforcing without competing
- re-trigger the active demo by re-keying on tab click, even when re-selecting the same tab