opacity-only 600ms vs 3-layer stagger
Bad vs Good
同じ削除確認モーダルを、quality rule を無視した実装と遵守した実装で並べて比較する。opacity だけの linear と、position・scale・backdrop・stagger を重ねた実装の体感差。
Bad · opacity only / linear / 600ms
本当に削除しますか?
opacity だけ・linear・600ms。重さも方向もない。
Good · 3層 + ease-out + stagger
本当に削除しますか?
position + scale + ambient backdrop。重さと方向が伝わる。
// notes
- opacity だけのアニメーションは変化の方向と重さを伝えられない。position・scale を組み合わせて初めて「どこから来たか」が伝わる。
- backdrop の ambient fade とモーダル本体の primary motion を分離すると、階層ごとの役割が明確になる。
- 見出し・本文・アクションボタンを stagger させると、情報が読む順番通りに現れて認知負荷が下がる。
- linear easing は機械的な等速運動に見える。ease-out 系(md3Emphasized)は自然な減速で着地する。
/**
* Bad vs Good — 同じ削除確認モーダルを quality rule 違反版と遵守版で並べる。
* 移植元: motion-playground app/_sections/BadVsGood.tsx(dark 変換 M-B-2)
*/
import { motion } from 'motion/react';
import { easings } from '../../../lib/motion-tokens/playground';
import { useLabReplay } from './use-lab-replay';
function BadModal({ playKey }: { playKey: number }) {
return (
<div className="relative h-72 overflow-hidden rounded-lg border border-[var(--border-subtle)] bg-[var(--bg-elevated)]">
<motion.div
key={playKey}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, ease: 'linear' }}
className="absolute inset-x-6 top-1/2 -translate-y-1/2 rounded-lg bg-[var(--bg-surface)] p-5 ring-1 ring-[var(--border-subtle)]"
>
<p className="text-sm font-medium text-[var(--text-primary)]">
本当に削除しますか?
</p>
<p className="mt-1 text-xs text-[var(--text-muted)]">
opacity だけ・linear・600ms。重さも方向もない。
</p>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
className="rounded-md px-3 py-1.5 text-sm text-[var(--text-secondary)]"
>
キャンセル
</button>
<button
type="button"
className="rounded-md bg-[var(--danger)] px-3 py-1.5 text-sm text-[var(--text-inverted)]"
>
削除する
</button>
</div>
</motion.div>
</div>
);
}
function GoodModal({ playKey }: { playKey: number }) {
return (
<div className="relative h-72 overflow-hidden rounded-lg border border-[var(--border-subtle)] bg-[var(--bg-elevated)]">
{/* Ambient: backdrop fade */}
<motion.div
key={`backdrop-${playKey}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.25, ease: easings.gentle }}
className="pointer-events-none absolute inset-0 bg-[rgba(0,0,0,0.3)] backdrop-blur-[2px]"
/>
{/* Primary: position + scale + opacity */}
<motion.div
key={`primary-${playKey}`}
initial={{ opacity: 0, y: 16, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
duration: 0.35,
ease: easings.md3Emphasized,
delay: 0.05,
}}
className="absolute inset-x-6 top-1/2 -translate-y-1/2 rounded-lg bg-[var(--bg-surface)] p-5"
style={{ boxShadow: '0 12px 32px rgba(0, 0, 0, 0.55)' }}
>
<motion.p
key={`title-${playKey}`}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: easings.gentle, delay: 0.12 }}
className="text-sm font-medium text-[var(--text-primary)]"
>
本当に削除しますか?
</motion.p>
<motion.p
key={`desc-${playKey}`}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: easings.gentle, delay: 0.18 }}
className="mt-1 text-xs text-[var(--text-muted)]"
>
position + scale + ambient backdrop。重さと方向が伝わる。
</motion.p>
<motion.div
key={`actions-${playKey}`}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: easings.gentle, delay: 0.24 }}
className="mt-4 flex justify-end gap-2"
>
<button
type="button"
className="rounded-md px-3 py-1.5 text-sm text-[var(--text-secondary)]"
>
キャンセル
</button>
<button
type="button"
className="rounded-md bg-[var(--danger)] px-3 py-1.5 text-sm text-[var(--text-inverted)]"
>
削除する
</button>
</motion.div>
</motion.div>
</div>
);
}
export default function BadVsGood() {
const { rootRef, playKey } = useLabReplay();
return (
<div ref={rootRef} data-lab-island className="w-full p-4 lg:p-6">
<div className="grid gap-6 sm:grid-cols-2">
<div>
<p className="mb-3 inline-flex items-center gap-1.5 rounded-md bg-[rgba(255,92,92,0.1)] px-2 py-0.5 text-xs font-medium text-[var(--danger)] ring-1 ring-[var(--border-strong)]">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--danger)]" />
Bad · opacity only / linear / 600ms
</p>
<BadModal playKey={playKey} />
</div>
<div>
<p className="mb-3 inline-flex items-center gap-1.5 rounded-md bg-[rgba(76,208,138,0.1)] px-2 py-0.5 text-xs font-medium text-[var(--success)] ring-1 ring-[var(--border-strong)]">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--success)]" />
Good · 3層 + ease-out + stagger
</p>
<GoodModal playKey={playKey} />
</div>
</div>
</div>
);
} Compare a bad vs good modal entrance with motion/react:
- Bad: opacity 0 -> 1, 600ms, linear (no position / scale change)
- Good layer 1 (backdrop): opacity 0 -> 1, 250ms, cubic-bezier(0.4, 0, 0.2, 1)
- Good layer 2 (modal body): y 16 -> 0, scale 0.96 -> 1, opacity 0 -> 1, 350ms, cubic-bezier(0.05, 0.7, 0.1, 1), delay 50ms
- Good layer 3 (title / description / actions): y 4 -> 0 + fade, 300ms, staggered 120 / 180 / 240ms
- rule: never animate opacity alone — pair with position + scale to communicate weight and direction