idle→loading→success/error · shake 400ms
State Feedback
ボタンの idle → loading → success / error 遷移。成功は bounce + checkmark draw、失敗は shake で表現する。opacity 単体での状態変化は禁止。
/**
* 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>
);
} Implement button state feedback with motion/react:
- states cycle: idle -> loading (900ms) -> success or error (held 1400ms) -> idle
- label swap via AnimatePresence mode="wait": opacity + translateY(6px), 180ms, cubic-bezier(0.2, 0, 0, 1)
- loading spinner: rotate 0 -> 360deg, 800ms, linear, infinite
- success: scale 0.6 -> 1 pop, 300ms, cubic-bezier(0.175, 0.885, 0.32, 1.275); checkmark draws via pathLength 0 -> 1 over 300ms
- error: shake keyframes [0, -8, 8, -6, 6, -3, 3, 0]px over 400ms + red background swap
- never signal a state change with opacity alone — pair it with position or scale