A toast stack where each trigger press springs in a new notification that reflows the list via layout animation, auto-dismisses after a timeout and slides out on dismiss.
Preview
No notifications yet
Customize
5 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add notification-stackWrites components/ui/notification-stack.tsx and components/styles/notification-stack.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { NotificationStack } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <NotificationStack />6 );7}Props
Accessibility
Toasts render in a <ul> with aria-live="polite" so new messages are announced. Every toast has a labelled dismiss <button type="button">, the trigger is a native button with a visible focus ring, and all auto-dismiss timers are cleared on unmount. Under prefers-reduced-motion toasts only fade, with no layout, slide or scale motion.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/notification-stack.module.css";6 7export interface NotificationStackProps {8 /** Messages cycled through on each trigger press */9 messages?: string[];10 /** Auto-dismiss delay in milliseconds (0 disables auto-dismiss) */11 duration?: number;12 /** Maximum number of toasts visible at once */13 maxVisible?: number;14 /** Trigger button label */15 triggerLabel?: string;16 /** Where new toasts appear relative to the stack */17 position?: "top" | "bottom";18 /** Optional className merged onto the root */19 className?: string;20}21 22interface Toast {23 id: number;24 text: string;25}26 27const DEFAULT_MESSAGES = ["Build succeeded", "New comment on your PR", "Deployed to production", "3 new stars"];28 29function BellIcon() {30 return (31 <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">32 <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />33 <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />34 </svg>35 );36}37 38export function NotificationStack({39 messages = DEFAULT_MESSAGES,40 duration = 3000,41 maxVisible = 4,42 triggerLabel = "Notify me",43 position = "top",44 className = "",45}: NotificationStackProps) {46 const prefersReducedMotion = useReducedMotion();47 const [items, setItems] = React.useState<Toast[]>([]);48 const nextId = React.useRef(0);49 const timers = React.useRef(new Map<number, ReturnType<typeof setTimeout>>());50 51 React.useEffect(() => {52 const map = timers.current;53 return () => {54 map.forEach((t) => clearTimeout(t));55 map.clear();56 };57 }, []);58 59 const dismiss = (id: number) => {60 const t = timers.current.get(id);61 if (t !== undefined) clearTimeout(t);62 timers.current.delete(id);63 setItems((list) => list.filter((x) => x.id !== id));64 };65 66 const add = () => {67 const id = nextId.current++;68 const pool = messages.length > 0 ? messages : DEFAULT_MESSAGES;69 const text = pool[id % pool.length] ?? "Notification";70 const limit = Math.max(1, Math.round(maxVisible));71 setItems((list) => (position === "top" ? [{ id, text }, ...list].slice(0, limit) : [...list, { id, text }].slice(-limit)));72 if (duration > 0) timers.current.set(id, setTimeout(() => dismiss(id), duration));73 };74 75 const enterY = position === "top" ? -20 : 20;76 77 return (78 <div className={`${styles.root} ${className}`.trim()}>79 <button type="button" onClick={add} className={styles.trigger}>80 {triggerLabel}81 </button>82 <ul aria-live="polite" aria-label="Notifications" className={styles.list}>83 <AnimatePresence initial={false}>84 {items.map((it) => (85 <motion.li86 key={it.id}87 layout={!prefersReducedMotion}88 initial={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: enterY, scale: 0.92 }}89 animate={{ opacity: 1, y: 0, scale: 1 }}90 exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: 80 }}91 transition={prefersReducedMotion ? { duration: 0.15 } : { type: "spring", stiffness: 400, damping: 30 }}92 className={styles.toast}93 >94 <span className={styles.icon}>95 <BellIcon />96 </span>97 <span className={styles.text}>{it.text}</span>98 <button type="button" aria-label={`Dismiss: ${it.text}`} onClick={() => dismiss(it.id)} className={styles.dismiss}>99 <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">100 <path d="M18 6 6 18M6 6l12 12" />101 </svg>102 </button>103 </motion.li>104 ))}105 </AnimatePresence>106 </ul>107 {items.length === 0 && <p className={styles.empty}>No notifications yet</p>}108 </div>109 );110}