Toast stack
Notifications that pile up like a deck of cards and fan out into a list when you reach for them.
toast-stack/toast-stack.tsxSuccess: Changes saved
Your edits are safe. Pick up where you left off anytime.
Info: Update available
A new version is ready. Reload to get the latest fixes.
Warning: Storage almost full
You’ve used 92% of your space. Archive old files soon.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Short news about something that just happened in the background: saved, sent, uploaded, failed.
- Messages that may arrive in bursts, where a pile reads better than a column taking over the screen.
- Feedback that needs no answer and can go away on its own after a few seconds.
- Status worth a glance but not a stop: the reader hovers the stack to read the older ones.
When not to use
- Errors the person must fix before going on. Show them inline, next to the field or the action.
- Anything that needs a decision. Use a dialog or a confirmation with the action named on its button.
- Information the reader will want later: toasts leave. Keep it in an inbox or an activity list.
- Long messages or links to act on: a toast holds a title and two lines, and its timer may run out first.
- A single, permanent notice about the page. Use a banner at the top instead.
You can add this component with the shadcn CLI or by hand:
Into components/toast-stack of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/toast-stack.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { ToastStack } from './toast-stack';// The stage, its toolbar and the stack, with a demo that plays while it is on screen.// The sample toasts are in samples.ts: change them there.export default function Notifications() { return <ToastStack duration={6000} limit={4} />;}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A toaster for an app’s notifications. New toasts spring in from below and stack up, each one behind a little smaller, higher and fainter; hover or tab into the stack and it fans out into a list, with every timer on hold. Swipe a toast sideways to send it away, or let its time run out.
- Every toast is the same height, so its place in the stack is all the stylesheet needs. Each toast gets its index as a custom property, --i, and the CSS turns it into a transform: stacked (a peek higher, 5% smaller, fainter) or fanned out (a row each). Nothing is measured, and the server’s frame is already in place.
- Every move is a CSS transition of transform and opacity on a spring: a damped oscillation sampled into a linear() easing, with an overshooting cubic-bezier as the fallback. A new toast springs in from below the stage’s edge with @starting-style.
- A toast leaves in two steps. Marked as leaving, it plays its exit from where it stood (a fade, or a flight off the side it was swiped to) while the others close up at once; it is removed when the exit is over.
- Each toast keeps its own timer and what is left of it. Timers wait while the stack is open, off screen or in a hidden tab. The front toast draws its time as a hairline, run by the Web Animations API in step with its timer; the others hold theirs still, so only one animation runs.
- A press that moves sideways becomes a swipe: the toast captures the pointer and follows it, fading. Past a third of its width, or with a flick, it flies off; otherwise it springs back. Vertical moves are left to the page.
- Its light and dark looks are light-dark() tokens, so the stage opens in the page’s color-scheme, or the colorScheme prop, and the toolbar’s switch shows which one it is in. Flipping it is a pick that holds until it is flipped again; defaultTheme makes the same pick from the start.
| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | 6000 | How long a toast stays, in ms, while nothing pauses it; Infinity keeps it until dismissed. |
limit | number | 4 | The most toasts the stack holds; a new one past it sends the oldest away. The stage grows to fit them fanned out. |
defaultTheme | 'light' | 'dark' | — | The theme to begin with, as if picked on the toolbar’s switch. Without it the stage follows the page’s color-scheme, or colorScheme. |
defaultToasts | readonly ToastContent[] | — | The toasts on the stage as it loads, newest first, each a kind, a title and a description; the first three samples without it. Setting it turns the demo off. |
defaultOpen | boolean | false | Opens fanned out, the timers waiting, until the pointer leaves the stack or a tap lands outside it. Turns the demo off. |
autoplay | boolean | true | Send a toast every few seconds, fan the stack and flick one away, while visible and untouched. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root, a stage of its own. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type RefObject,} from 'react';import { ClearIcon, MoonIcon, PlusIcon, SunIcon } from './icons';import { KIND_LABELS, SAMPLES, type ToastContent } from './samples';import { Toast, type Leave } from './toast';import { useAutoplay } from './use-autoplay';import styles from './toast-stack.module.css';/* * Toast stack: notifications that pile up at the bottom of a stage like a deck of cards, the * newest in front and each one behind a little smaller, higher and fainter. Hover the stack, or * tab into it, and it fans out into a list; the timers wait while it is open. */* Toast stack (toast-stack.tsx): a stage, light or dark, with a floating toolbar at the top and the toasts at the bottom. A toast is placed by its index alone, `--i` (0 for the newest): every toast is the same height, so the stylesheet can work out where each one goes, stacked or fanned out. Narrow stages (a container query) give the description a third line. Colours are the tokens below, light-dark(light, dark): the stage takes the color-scheme it inherits from the page, or the one the colorScheme prop or the toolbar's switch sets (data-color-scheme on the root). */.root { container-type: inline-size; min-width: 0; /* For the script: a colour the root never shows (it has no outline), white in light and black in dark, which tells the switch the scheme the root is drawn in. */ outline-color: #fff;}@supports (color: light-dark(#fff, #000)) { .root { outline-color: light-dark(#fff, #000); }}/* The colorScheme prop, or the reader's pick on the switch. Without either, nothing is set: color-scheme is inherited from the page. */.root[data-color-scheme='light'] {'use client';import { useEffect, useRef, type CSSProperties, type PointerEvent } from 'react';import { CloseIcon, KindIcon } from './icons';import { KIND_LABELS, type ToastContent } from './samples';import styles from './toast-stack.module.css';/* * One toast: a tinted icon, a title, a description, a close button, a timer, and a swipe to send * it away. Where it sits is the stylesheet's job: the toast only hands on its place in the stack * (`--i`, 0 for the newest) and its state, as data attributes. * * - The timer counts down while `paused` is false and keeps what is left across pauses. A * hairline along the bottom edge shows what is left: on the toast in front it runs down with the * timer (the Web Animations API, started and stopped with it); on the others it holds still, * hidden until the stack opens, so only one animation ever runs. * - A press that travels sideways becomes a swipe: the toast follows the pointer (pointer capture), * fading as it goes. Let go past a third of its width, or with a flick, and it flies off that * side; short of that, it springs back. Up and down are left to the page, so a phone still * scrolls (touch-action: pan-y). *//** How a toast leaves: it fades (its timer, its close button, Clear), or flies off a side (a swipe). */export type Leave = 'fade' | 'left' | 'right';/* * What a toast says, and the demo's sample toasts. The first three are on the stage when it * loads (the third in front); the toolbar's Add button and the demo go on from the fourth, round * and round, so the four kinds take turns. */export type ToastKind = 'success' | 'error' | 'info' | 'warning';export type ToastContent = { kind: ToastKind; title: string; /** Up to two lines: every toast is the same height, which keeps the stack's maths in CSS. */ description: string;};/** How each kind is named to screen readers, before the title. */export const KIND_LABELS: Record<ToastKind, string> = { success: 'Success', error: 'Error', info: 'Info', warning: 'Warning',};export const SAMPLES: readonly ToastContent[] = [import type { ReactNode } from 'react';import type { ToastKind } from './samples';/* * The toast stack's icons: a glyph for each kind of toast (drawn in the kind's colour by the * stylesheet, on a tile of the same colour, lighter), the close button's cross, and the toolbar's. * All decorative: the buttons and toasts around them carry the words. */const GLYPHS: Record<ToastKind, ReactNode> = { success: <path d="M5.5 10.4 8.6 13.4 14.5 7" />, error: <path d="M6.8 6.8 13.2 13.2M13.2 6.8 6.8 13.2" />, info: ( <> <path d="M10 9.4v5" /> <circle cx="10" cy="6.2" r="1.15" fill="currentColor" stroke="none" /> </> ), warning: ( <> <path d="M10 5.6v5" /> <circle cx="10" cy="14" r="1.15" fill="currentColor" stroke="none" /> </> ),'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The toasts are a list labelled “Notifications”. Each one says its kind before its title (“Error: Upload failed”) and has a Dismiss button, so swiping is never the only way to dismiss.
- Tabbing into the stack fans it out. Dismissing a toast from the keyboard hands focus to the next toast, or to Add.
- Toasts added from the toolbar are announced in a polite live region; the demo’s own toasts are not, so it never talks over the page. Timers stop while the stack is open, off screen or in a hidden tab.
- Under reduced motion there is no demo, no spring and no countdown animation: toasts appear and leave at once, and still time out.