linear vs md3 vs apple vs bounce · 1.2s

Easing Lab

同じ移動を 7 種類の easing で並べ、曲線形状と着地の体感の対応を比較する。linear は spinner と progress 以外で使わない。

linear vs md3 vs apple vs bounce · 1.2s

linear

spinner 以外で禁止

MD3 Standard

default on-screen

MD3 Emphasized

entrance / attention

MD3 Accelerate

exit / dismiss

Apple HIG

iOS standard

Gentle

ambient / premium

Bounce Settle

playful overshoot

/**
 * Easing Lab — 同じ移動を7種類の easing で並べて比較する。
 * 移植元: motion-playground app/_sections/EasingLab.tsx(dark 変換 M-B-2)
 */
import { motion } from 'motion/react';
import { easings, type CubicBezier } from '../../../lib/motion-tokens/playground';
import { useLabReplay } from './use-lab-replay';
import { useTrackWidth } from './use-track-width';

const lanes: {
  key: string;
  label: string;
  note: string;
  ease: CubicBezier | 'linear';
}[] = [
  { key: 'linear', label: 'linear', note: 'spinner 以外で禁止', ease: 'linear' },
  {
    key: 'md3-standard',
    label: 'MD3 Standard',
    note: 'default on-screen',
    ease: easings.md3Standard,
  },
  {
    key: 'md3-emphasized',
    label: 'MD3 Emphasized',
    note: 'entrance / attention',
    ease: easings.md3Emphasized,
  },
  {
    key: 'md3-accelerate',
    label: 'MD3 Accelerate',
    note: 'exit / dismiss',
    ease: easings.md3Accelerate,
  },
  {
    key: 'apple',
    label: 'Apple HIG',
    note: 'iOS standard',
    ease: easings.appleStandard,
  },
  {
    key: 'gentle',
    label: 'Gentle',
    note: 'ambient / premium',
    ease: easings.gentle,
  },
  {
    key: 'bounce',
    label: 'Bounce Settle',
    note: 'playful overshoot',
    ease: easings.bounceSettle,
  },
];

function CurvePreview({ ease }: { ease: CubicBezier | 'linear' }) {
  // bezier curve preview in a 60x40 viewbox (y軸反転で右肩上がり)
  const path =
    ease === 'linear'
      ? 'M 0 40 L 60 0'
      : `M 0 40 C ${ease[0] * 60} ${(1 - ease[1]) * 40}, ${ease[2] * 60} ${(1 - ease[3]) * 40}, 60 0`;

  return (
    <svg
      viewBox="-2 -8 64 56"
      className="h-12 w-16 shrink-0"
      aria-hidden="true"
    >
      <path d="M 0 40 L 60 40" stroke="var(--border-subtle)" strokeWidth="1" />
      <path d="M 0 40 L 0 0" stroke="var(--border-subtle)" strokeWidth="1" />
      <path
        d={path}
        stroke="var(--text-secondary)"
        strokeWidth="1.5"
        fill="none"
        strokeLinecap="round"
      />
    </svg>
  );
}

function Lane({
  ease,
  label,
  note,
  playKey,
}: {
  ease: CubicBezier | 'linear';
  label: string;
  note: string;
  playKey: number;
}) {
  const [trackRef, travel] = useTrackWidth(28, 12);
  return (
    <div className="flex items-center gap-4 border-b border-[var(--border-subtle)] py-3 last:border-none">
      <CurvePreview ease={ease} />
      <div className="w-36 shrink-0">
        <p className="text-sm font-medium text-[var(--text-primary)]">{label}</p>
        <p className="mt-0.5 text-xs text-[var(--text-muted)]">{note}</p>
      </div>
      <div
        ref={trackRef}
        className="relative h-10 flex-1 overflow-hidden rounded-md bg-[var(--bg-elevated)]"
      >
        <motion.div
          key={playKey}
          initial={{ x: 0 }}
          animate={{ x: travel }}
          transition={{ duration: 1.2, ease }}
          className="absolute top-1.5 left-1.5 h-7 w-7 rounded-md bg-[var(--text-primary)]"
        />
      </div>
    </div>
  );
}

export default function EasingLab() {
  const { rootRef, playKey } = useLabReplay();

  return (
    <div ref={rootRef} data-lab-island className="w-full p-4 lg:p-6">
      <div className="rounded-lg border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-2">
        {lanes.map((lane) => (
          <Lane
            key={lane.key}
            ease={lane.ease}
            label={lane.label}
            note={lane.note}
            playKey={playKey}
          />
        ))}
      </div>
    </div>
  );
}