squash · anticipation · arcs · follow-through · secondary

Disney 12 Principles

『The Illusion of Life』の 12 原則のうち UI モーションに直結する 5 つをタブで切り替えて体感する教材。物理法則をどう嘘でリアルに見せるか。

squash · anticipation · arcs · follow-through · secondary

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>
  );
}