Feedback

Toast Stack

A self-contained toast system — newest slides in, older ones stack behind it, hover expands the pile into a list.

Preview in your theme

Loading preview…

Dependencies
lucide-react
Registry dependencies
utils, use-prefers-reduced-motion
"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { CircleCheck, CircleX, Info, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"

/**
 * Enter and exit both run on keyframes (a React 19 hoisted <style>, deduped by href).
 * The enter animation declares only `from`: the implicit `to` is the element's current
 * inline transform, so a toast always slides to exactly where it sits in the stack and
 * never jumps once the animation ends.
 */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/toast-stack.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ToastStack" notification system with
lucide-react icons and no toast library — React context, a portal and CSS
keyframes are the whole runtime.

Contract
- Shared primitive: use-prefers-reduced-motion (installed automatically via
  registryDependencies) — usePrefersReducedMotion() switches the stack to the
  plain expanded list with synchronous dismissal. If rebuilding without it: a
  useSyncExternalStore over matchMedia("(prefers-reduced-motion: reduce)") with
  a "change" listener and a server snapshot of false.
- <ToastProvider position="bottom-right" max={3} duration={4000} className?>
  wraps the subtree and renders its viewport through createPortal into
  document.body. position is one of top-left / top-right / bottom-left /
  bottom-right.
- useToast() returns { toast, dismiss, dismissAll }. toast({ title,
  description?, variant?: "default" | "success" | "error" | "info", duration?,
  action?: { label, onClick } }) pushes a notification and returns its id;
  dismiss(id) and dismissAll() remove them. Called outside a provider,
  useToast() throws a message naming <ToastProvider> — a silent no-op would
  hide the bug until production.
- Consumers own every action: action.onClick is theirs, and the toast closes
  itself after running it.

Behavior
- The queue lives in a ref that state mirrors, because toast() is called from
  event handlers and must see the result of the call before it — pushing three
  toasts in one tick has to enforce `max` against all three.
- Overflow: once more than `max` are alive, the oldest starts leaving. Newest
  is always depth 0, drawn in front, with the highest z-index.
- Collapsed stack: only the front toast is fully visible; the toasts behind are
  clamped to the front one's measured height with overflow hidden, translated
  by a fixed peek offset and scaled down one step per depth, so only their top
  edges show. Past three deep they fade to zero opacity.
- Hover or focus anywhere in the viewport expands the stack into a real list:
  each toast returns to its natural height and offsets become the running sum
  of the heights in front of it plus a gap. Heights come from measuring each
  card with a stable ref callback (an inline ref callback re-attaches every
  render and will ping-pong with the height state); a window resize listener
  re-measures and is removed on unmount.
- The viewport's own height is set explicitly and transitions, so the hover
  target always matches the visible stack exactly — no invisible blocker over
  the page, no flicker when the pointer crosses the gap between two toasts.
- Timers: each toast gets its own dismiss timer holding { remaining, startedAt,
  timeout }. Entering the viewport clears the timeouts and subtracts the
  elapsed time; leaving restarts them from the remainder — the countdown pauses
  while the user is reading, and pointer and focus are tracked separately so
  tabbing through also pauses. duration 0 or Infinity means sticky.
- Exit: dismissing marks the toast `exiting` and plays a fade-and-shrink
  keyframe; the element removes itself in onAnimationEnd (matched by
  animationName). A fallback timer covers the case where the tab is
  backgrounded and the animation event never arrives. Depth is computed over
  the whole list including exiting toasts, so a leaving toast fades where it
  stands instead of everything collapsing onto one spot during dismissAll.
- Removing a toast while focus was inside the viewport schedules one
  requestAnimationFrame that re-derives the focus-inside flag from
  document.activeElement: the browser fires no focusout when the focused
  button unmounts with its toast.
- Every timeout is cleared on unmount, and so is the pending focus re-check
  requestAnimationFrame.
- prefers-reduced-motion: no slide-in, no stack, no exit animation — the
  viewport renders permanently expanded as a plain list, and dismissing removes
  the toast synchronously (there is no animation event to wait for) through the
  same remove path as the animated exit, so it also schedules that focus
  re-check — a focus-inside flag left at true would keep every later toast
  paused forever.

Rendering & styling
- Semantic tokens only: cards are bg-popover / text-popover-foreground with a
  border and shadow; icons are text-[var(--chart-2)] for success,
  text-destructive for error, text-primary for info,
  text-muted-foreground for default; descriptions are text-muted-foreground.
- Accessibility: the viewport is role="region" aria-label="Notifications"
  aria-live="polite"; an error toast overrides with role="alert" and
  aria-live="assertive". The close button is an icon button with
  aria-label="Dismiss"; icons are aria-hidden; every interactive element keeps a
  focus-visible ring, and focusing a buried toast expands the stack so it is
  actually visible.
- Keyframes ship in the component via a React 19 hoisted <style href
  precedence> tag. The viewport only portals after mount (a client-only
  snapshot through useSyncExternalStore), so SSR never touches document.

Customization levers
- Density and depth: the peek offset, scale step, visible-depth cap and
  expanded gap are four constants at the top of the file — raising the peek and
  lowering the scale step gives a flatter, more "list-like" stack.
- Width and placement: the viewport class holds the width clamp and corner
  offsets; className on the provider merges into it for edge-to-edge mobile
  toasts or a wider desktop column.
- Variants: the icon map and icon-color map are the entire variant system — add
  a "warning" entry with an OctagonAlert icon and a chart token, no other
  branch changes.
- Card anatomy: title, description, action and close button are independent
  slots; drop the description for terse toasts, or add a leading avatar for
  social notifications.
- Timing: duration per provider, overridable per toast; hand Infinity to a
  toast that must be acknowledged (it keeps its close button and its action).

Concepts

  • Imperative queue, declarative viewport — toast() is called from event handlers like any side effect, while a single portal-rendered viewport owns all the layout; call sites never render a toast themselves.
  • Collapsed stack with peek edges — clamping the toasts behind to the front one's height turns a variable-height pile into an even set of edges, which is what makes the stack read as one object rather than overlapping cards.
  • Hover to expand — the stack is the resting state and the list is the inspection state; expansion is also the moment the countdown stops, so reading and dismissing never race the timer.
  • Pausable timers — banking the remaining time on pause and rescheduling from that remainder on resume keeps a toast's total visible lifetime honest no matter how often the user hovers over it.
  • Depth includes leaving toasts — computing positions over the full list means a dismissed toast fades in place while the rest hold, instead of everything sliding onto one spot mid-animation.
  • Self-removal on animation end — the exit is finalized by the animation event rather than a matched timeout, with a fallback timer only for backgrounded tabs where the event may never fire.
  • Provider-scoped errors — useToast() throwing outside its provider turns a whole class of "the toast just never appeared" bugs into an immediate, named failure.

On This Page