linear vs md3 vs apple vs bounce · 1.2s
Easing Lab
同じ移動を 7 種類の easing で並べ、曲線形状と着地の体感の対応を比較する。linear は spinner と progress 以外で使わない。
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>
);
} .ease-lane { transition: transform 1.2s var(--ease); }
.ease-lane.play { transform: translateX(var(--travel)); }
.ease-linear { --ease: linear; }
.ease-md3-standard { --ease: cubic-bezier(0.2, 0, 0, 1); }
.ease-md3-emphasized { --ease: cubic-bezier(0.05, 0.7, 0.1, 1); }
.ease-md3-accelerate { --ease: cubic-bezier(0.3, 0, 1, 1); }
.ease-apple { --ease: cubic-bezier(0.25, 0.1, 0.25, 1); }
.ease-gentle { --ease: cubic-bezier(0.4, 0, 0.2, 1); }
.ease-bounce { --ease: cubic-bezier(0.175, 0.885, 0.32, 1.275); } Build an easing comparison with 7 lanes, one per curve:
- linear / md3-standard cubic-bezier(0.2, 0, 0, 1) / md3-emphasized cubic-bezier(0.05, 0.7, 0.1, 1) / md3-accelerate cubic-bezier(0.3, 0, 1, 1) / apple-hig cubic-bezier(0.25, 0.1, 0.25, 1) / gentle cubic-bezier(0.4, 0, 0.2, 1) / bounce cubic-bezier(0.175, 0.885, 0.32, 1.275)
- each lane: small SVG bezier preview + label + a track with a moving block
- animate translateX 0 -> (track width - block - padding) over 1.2s with the lane's easing
- measure track width via a resize-aware hook (element x is relative to its own size)
- replay restarts all lanes simultaneously via a shared key