enter/exit x ±24 · height 0→auto · 250ms

List AnimatePresence

リストへの追加・削除・シャッフルを AnimatePresence と layout で表現する。要素の出入りと並び替えが spring でつながる。

enter/exit x ±24 · height 0→auto · 250ms
  • Inbox
  • Followups
  • Archive
/**
 * List AnimatePresence — リスト要素の出入り choreography(add / remove / shuffle)。
 * 移植元: motion-playground app/_sections/ListAnimatePresence.tsx(dark 変換 M-B-2)
 */
import { AnimatePresence, motion } from 'motion/react';
import { useState } from 'react';
import { easings } from '../../../lib/motion-tokens/playground';

type Item = { id: number; label: string };

const seed: Item[] = [
  { id: 1, label: 'Inbox' },
  { id: 2, label: 'Followups' },
  { id: 3, label: 'Archive' },
];

const candidates = [
  'Drafts',
  'Sent',
  'Snoozed',
  'Spam',
  'Trash',
  'Important',
  'All mail',
];

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

export default function ListAnimatePresence() {
  const [items, setItems] = useState<Item[]>(seed);
  const [nextId, setNextId] = useState(seed.length + 1);

  function addItem() {
    const label = candidates[(nextId - 1) % candidates.length];
    setItems((prev) => [...prev, { id: nextId, label }]);
    setNextId((n) => n + 1);
  }

  function removeItem(id: number) {
    setItems((prev) => prev.filter((it) => it.id !== id));
  }

  function shuffle() {
    setItems((prev) => [...prev].sort(() => Math.random() - 0.5));
  }

  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={addItem} className={BTN_PRIMARY}>
          + アイテムを追加
        </button>
        <button type="button" onClick={shuffle} className={BTN_GHOST}>
          ⇅ シャッフル
        </button>
        <button
          type="button"
          onClick={() => setItems(seed)}
          className={BTN_GHOST}
        >
          リセット
        </button>
      </div>

      <div className="rounded-xl border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-3">
        <motion.ul layout className="space-y-2">
          <AnimatePresence initial={false}>
            {items.map((it) => (
              <motion.li
                key={it.id}
                layout
                initial={{ opacity: 0, x: -24, height: 0 }}
                animate={{ opacity: 1, x: 0, height: 'auto' }}
                exit={{ opacity: 0, x: 24, height: 0 }}
                transition={{
                  duration: 0.25,
                  ease: easings.md3Emphasized,
                }}
                className="flex items-center justify-between rounded-md bg-[var(--bg-elevated)] px-3 py-2"
              >
                <span className="text-sm text-[var(--text-primary)]">
                  {it.label}
                </span>
                <button
                  type="button"
                  onClick={() => removeItem(it.id)}
                  className="rounded p-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)] hover:text-[var(--text-secondary)]"
                  aria-label={`${it.label} を削除`}
                >
                  ✕
                </button>
              </motion.li>
            ))}
          </AnimatePresence>
        </motion.ul>
      </div>
    </div>
  );
}