idle→loading→success/error · shake 400ms

State Feedback

ボタンの idle → loading → success / error 遷移。成功は bounce + checkmark draw、失敗は shake で表現する。opacity 単体での状態変化は禁止。

idle→loading→success/error · shake 400ms
/**
 * State Feedback — idle → loading → success / error のボタン状態遷移。
 * 移植元: motion-playground app/_sections/StateFeedback.tsx(dark 変換 M-B-2)
 * ボタン操作で発火するシーケンスのため useLabReplay は不要。
 */
import { AnimatePresence, motion } from 'motion/react';
import { useState } from 'react';
import { easings } from '../../../lib/motion-tokens/playground';

type ButtonState = 'idle' | 'loading' | 'success' | 'error';

function StatefulButton() {
  const [state, setState] = useState<ButtonState>('idle');

  async function handleClick(target: 'success' | 'error') {
    setState('loading');
    await new Promise((r) => setTimeout(r, 900));
    setState(target);
    await new Promise((r) => setTimeout(r, 1400));
    setState('idle');
  }

  return (
    <div className="flex flex-col items-center gap-6">
      <motion.button
        type="button"
        onClick={() => state === 'idle' && handleClick('success')}
        animate={
          state === 'error'
            ? {
                x: [0, -8, 8, -6, 6, -3, 3, 0],
                transition: { duration: 0.4, ease: 'easeInOut' },
              }
            : { x: 0 }
        }
        whileTap={state === 'idle' ? { scale: 0.97 } : undefined}
        disabled={state !== 'idle'}
        className={`relative flex h-12 w-56 items-center justify-center overflow-hidden rounded-md px-4 font-medium text-[var(--text-inverted)] transition-colors ${
          state === 'success'
            ? 'bg-[var(--success)]'
            : state === 'error'
              ? 'bg-[var(--danger)]'
              : state === 'loading'
                ? 'bg-[var(--text-secondary)]'
                : 'bg-[var(--text-primary)]'
        }`}
      >
        <AnimatePresence mode="wait" initial={false}>
          {state === 'idle' && (
            <motion.span
              key="idle"
              initial={{ opacity: 0, y: 6 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -6 }}
              transition={{ duration: 0.18, ease: easings.md3Standard }}
            >
              Save changes
            </motion.span>
          )}
          {state === 'loading' && (
            <motion.span
              key="loading"
              initial={{ opacity: 0, y: 6 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -6 }}
              transition={{ duration: 0.18, ease: easings.md3Standard }}
              className="flex items-center gap-2"
            >
              <motion.span
                animate={{ rotate: 360 }}
                transition={{
                  duration: 0.8,
                  ease: 'linear',
                  repeat: Infinity,
                }}
                className="block h-4 w-4 rounded-full border-2 border-[var(--text-inverted)]/30 border-t-[var(--text-inverted)]"
              />
              Saving…
            </motion.span>
          )}
          {state === 'success' && (
            <motion.span
              key="success"
              initial={{ opacity: 0, scale: 0.6 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.9 }}
              transition={{ duration: 0.3, ease: easings.bounceSettle }}
              className="flex items-center gap-2"
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="3"
                strokeLinecap="round"
                strokeLinejoin="round"
                className="h-5 w-5"
                aria-hidden="true"
              >
                <motion.path
                  d="m5 13 4 4L19 7"
                  initial={{ pathLength: 0 }}
                  animate={{ pathLength: 1 }}
                  transition={{ duration: 0.3, ease: easings.md3Standard }}
                />
              </svg>
              Saved
            </motion.span>
          )}
          {state === 'error' && (
            <motion.span
              key="error"
              initial={{ opacity: 0, y: 6 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -6 }}
              transition={{ duration: 0.18, ease: easings.md3Standard }}
            >
              Try again
            </motion.span>
          )}
        </AnimatePresence>
      </motion.button>

      <div className="flex gap-3">
        <button
          type="button"
          onClick={() => state === 'idle' && handleClick('success')}
          disabled={state !== 'idle'}
          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)] disabled:opacity-50"
        >
          → success に遷移
        </button>
        <button
          type="button"
          onClick={() => state === 'idle' && handleClick('error')}
          disabled={state !== 'idle'}
          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)] disabled:opacity-50"
        >
          → error に遷移
        </button>
      </div>
    </div>
  );
}

export default function StateFeedback() {
  return (
    <div data-lab-island className="w-full p-4 lg:p-6">
      <div className="rounded-xl border border-[var(--border-subtle)] bg-gradient-to-b from-[var(--bg-surface)] to-[var(--bg-elevated)] px-6 py-12">
        <StatefulButton />
      </div>
    </div>
  );
}