enter x 60→0 · 300ms · auto-dismiss 3.5s

Toast Notification

ボタン操作でトーストを発火し、右下からスライドインして 3.5 秒後に自動退場する。複数発火時は AnimatePresence + layout でスタックが自然に整列する。

enter x 60→0 · 300ms · auto-dismiss 3.5s

ボタンを押すと右下に積まれる →

/**
 * Toast Notification — entrance + auto exit + stack。
 * 移植元: motion-playground app/_sections/ToastNotification.tsx(dark 変換 M-B-2)
 */
import { AnimatePresence, motion } from 'motion/react';
import { useState } from 'react';
import { easings } from '../../../lib/motion-tokens/playground';

type Toast = {
  id: number;
  variant: 'success' | 'info' | 'error';
  message: string;
};

const messages: Record<Toast['variant'], string[]> = {
  success: ['保存しました', '送信が完了しました', 'コピーしました'],
  info: ['新しい更新があります', '同期中…', 'プレビューを開きました'],
  error: ['接続できませんでした', 'ファイルが大きすぎます', '認証が切れました'],
};

const colors: Record<
  Toast['variant'],
  { text: string; ring: string; dot: string }
> = {
  success: {
    text: 'text-[var(--success)]',
    ring: 'ring-[var(--border-strong)]',
    dot: 'bg-[var(--success)]',
  },
  info: {
    text: 'text-[var(--text-primary)]',
    ring: 'ring-[var(--border-strong)]',
    dot: 'bg-[var(--text-secondary)]',
  },
  error: {
    text: 'text-[var(--danger)]',
    ring: 'ring-[var(--border-strong)]',
    dot: 'bg-[var(--danger)]',
  },
};

const BTN =
  'rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-3 py-1.5 text-sm font-medium text-[var(--text-primary)] transition-colors hover:bg-[var(--border-subtle)]';

export default function ToastNotification() {
  const [toasts, setToasts] = useState<Toast[]>([]);
  const [nextId, setNextId] = useState(1);

  function push(variant: Toast['variant']) {
    const pool = messages[variant];
    const message = pool[Math.floor(((nextId * 7919) % pool.length) | 0)];
    const id = nextId;
    setNextId((n) => n + 1);
    setToasts((prev) => [...prev, { id, variant, message }]);
    setTimeout(() => {
      setToasts((prev) => prev.filter((t) => t.id !== id));
    }, 3500);
  }

  return (
    <div data-lab-island className="w-full p-4 lg:p-6">
      <div className="mb-4 flex flex-wrap gap-2">
        <button type="button" onClick={() => push('success')} className={BTN}>
          success を出す
        </button>
        <button type="button" onClick={() => push('info')} className={BTN}>
          info を出す
        </button>
        <button type="button" onClick={() => push('error')} className={BTN}>
          error を出す
        </button>
        <button
          type="button"
          onClick={() => setToasts([])}
          className={BTN}
        >
          全部閉じる
        </button>
      </div>
      <div className="relative h-72 overflow-hidden rounded-lg border border-[var(--border-subtle)] bg-[var(--bg-surface)]">
        <div className="absolute right-4 bottom-4 flex w-[280px] flex-col gap-2">
          <AnimatePresence>
            {toasts.map((t) => {
              const c = colors[t.variant];
              return (
                <motion.div
                  key={t.id}
                  layout
                  initial={{ opacity: 0, x: 60, scale: 0.95 }}
                  animate={{ opacity: 1, x: 0, scale: 1 }}
                  exit={{ opacity: 0, x: 60, scale: 0.95 }}
                  transition={{
                    duration: 0.3,
                    ease: easings.md3Emphasized,
                  }}
                  className={`flex items-center gap-3 rounded-md bg-[var(--bg-elevated)] p-3 ring-1 ${c.ring}`}
                >
                  <span className={`h-2 w-2 rounded-full ${c.dot}`} />
                  <span className={`text-sm ${c.text}`}>{t.message}</span>
                </motion.div>
              );
            })}
          </AnimatePresence>
        </div>
        <p className="absolute top-4 left-4 text-xs text-[var(--text-muted)]">
          ボタンを押すと右下に積まれる →
        </p>
      </div>
    </div>
  );
}