fade / slide / zoom / push · 300–350ms · mode wait

Page Transitions

fade・slide・zoom・push の 4 パターンの画面遷移を切り替えて比較する。AnimatePresence mode="wait" で退場完了後に入場する。

fade / slide / zoom / push · 300–350ms · mode wait

opacity only · 安全

Page A

Dashboard summary view

/**
 * Page Transitions — fade / slide / zoom / push の4遷移パターンを切り替えて比較。
 * 移植元: motion-playground app/_sections/PageTransitions.tsx(dark 変換 M-B-2)
 * tab + 次のページへボタンのユーザー操作駆動のため useLabReplay は不要。
 */
import { AnimatePresence, motion } from 'motion/react';
import { useState } from 'react';
import { easings } from '../../../lib/motion-tokens/playground';

type Variant = 'fade' | 'slide' | 'zoom' | 'push';

const variants: { key: Variant; label: string; note: string }[] = [
  { key: 'fade', label: 'Fade', note: 'opacity only · 安全' },
  { key: 'slide', label: 'Slide', note: 'horizontal push · context shift' },
  { key: 'zoom', label: 'Zoom', note: 'scale focus · drill-down' },
  { key: 'push', label: 'Push', note: 'stack push · iOS-like' },
];

function getVariants(variant: Variant) {
  switch (variant) {
    case 'fade':
      return {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0 },
        transition: { duration: 0.3, ease: easings.gentle },
      };
    case 'slide':
      return {
        initial: { x: 60, opacity: 0 },
        animate: { x: 0, opacity: 1 },
        exit: { x: -60, opacity: 0 },
        transition: { duration: 0.35, ease: easings.md3Emphasized },
      };
    case 'zoom':
      return {
        initial: { scale: 0.92, opacity: 0 },
        animate: { scale: 1, opacity: 1 },
        exit: { scale: 1.08, opacity: 0 },
        transition: { duration: 0.3, ease: easings.md3Standard },
      };
    case 'push':
      return {
        initial: { y: 24, opacity: 0 },
        animate: { y: 0, opacity: 1 },
        exit: { y: -24, opacity: 0 },
        transition: { duration: 0.35, ease: easings.md3Emphasized },
      };
  }
}

export default function PageTransitions() {
  const [variant, setVariant] = useState<Variant>('fade');
  const [page, setPage] = useState(0);
  const v = getVariants(variant);

  const pages = [
    {
      title: 'Page A',
      body: 'Dashboard summary view',
      bg: 'from-sky-500/10 to-indigo-500/10',
    },
    {
      title: 'Page B',
      body: 'Detail view of selected item',
      bg: 'from-amber-500/10 to-rose-500/10',
    },
    {
      title: 'Page C',
      body: 'Settings & preferences',
      bg: 'from-emerald-500/10 to-teal-500/10',
    },
  ];

  return (
    <div data-lab-island className="w-full p-4 lg:p-6">
      <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
        <div role="tablist" className="flex flex-wrap gap-1.5">
          {variants.map((item) => (
            <button
              key={item.key}
              type="button"
              role="tab"
              aria-selected={variant === item.key}
              onClick={() => setVariant(item.key)}
              className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
                variant === item.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)]'
              }`}
            >
              {item.label}
            </button>
          ))}
        </div>
        <button
          type="button"
          onClick={() => setPage((p) => (p + 1) % pages.length)}
          className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-3 py-1.5 text-sm text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-elevated)]"
        >
          次のページへ →
        </button>
      </div>

      <p className="mb-3 text-xs text-[var(--text-muted)]">
        {variants.find((x) => x.key === variant)?.note}
      </p>

      <div className="relative h-56 overflow-hidden rounded-xl border border-[var(--border-subtle)] bg-[var(--bg-surface)]">
        <AnimatePresence mode="wait">
          <motion.div
            key={`${variant}-${page}`}
            {...v}
            className={`absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-br ${pages[page].bg}`}
          >
            <p className="text-2xl font-semibold text-[var(--text-primary)]">
              {pages[page].title}
            </p>
            <p className="mt-1 text-sm text-[var(--text-secondary)]">{pages[page].body}</p>
          </motion.div>
        </AnimatePresence>
      </div>
    </div>
  );
}