opacity-only 600ms vs 3-layer stagger

Bad vs Good

同じ削除確認モーダルを、quality rule を無視した実装と遵守した実装で並べて比較する。opacity だけの linear と、position・scale・backdrop・stagger を重ねた実装の体感差。

opacity-only 600ms vs 3-layer 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>
  );
}