Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

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.tsx
Live · interactive
  1. Success: Changes saved

    Your edits are safe. Pick up where you left off anytime.

  2. Info: Update available

    A new version is ready. Reload to get the latest fixes.

  3. 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:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/toast-stack.json

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

notifications.tsx
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.
PropTypeDefaultDescription
durationnumber6000How long a toast stays, in ms, while nothing pauses it; Infinity keeps it until dismissed.
limitnumber4The 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.
defaultToastsreadonly 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.
defaultOpenbooleanfalseOpens fanned out, the timers waiting, until the pointer leaves the stack or a tap lands outside it. Turns the demo off.
autoplaybooleantrueSend 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.
classNamestring—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.

toast-stack.tsx
// 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.module.css
/* 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'] {
toast.tsx
'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';
samples.ts
/* * 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[] = [
icons.tsx
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-autoplay.ts
'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.