Studio KitLoaders and Feedbackmotion/react

NotificationStack

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 props
    3000
    4

    Installation

    Copies the component source and its CSS Module into your project.

    npx motionfoundry add notification-stack

    Writes 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.

    example.tsx
    1import { NotificationStack } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <NotificationStack />6  );7}

    Props

    PropTypeDefault
    messages

    Messages cycled through on each trigger press.

    string[]['Build succeeded', 'New comment on your PR', 'Deployed to production', '3 new stars']
    duration

    Auto-dismiss delay in milliseconds; 0 disables auto-dismiss.

    number3000
    maxVisible

    Maximum number of toasts visible at once; oldest are dropped.

    number4
    triggerLabel

    Label of the trigger button.

    string'Notify me'
    position

    Whether new toasts appear at the top or bottom of the stack.

    'top' | 'bottom''top'
    className

    Additional class merged onto the root element.

    string''

    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

    components/ui/notification-stack.tsx
    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}