enter x 60→0 · 300ms · auto-dismiss 3.5s
Toast Notification
ボタン操作でトーストを発火し、右下からスライドインして 3.5 秒後に自動退場する。複数発火時は AnimatePresence + layout でスタックが自然に整列する。
ボタンを押すと右下に積まれる →
/**
* 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>
);
} Implement a toast stack with motion/react:
- anchor toasts bottom-right; state is an array of { id, variant, message }
- enter: initial { opacity: 0, x: 60, scale: 0.95 } -> { opacity: 1, x: 0, scale: 1 }
- transition 300ms, ease cubic-bezier(0.05, 0.7, 0.1, 1)
- auto-dismiss after 3500ms; exit reverses the enter animation
- wrap the list in AnimatePresence so exiting toasts animate out
- add `layout` to each toast so the stack reflows smoothly
- distinguish success / info / error by a status dot color, not by shape