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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/toast-stack.jsonPrompt
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
- <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.
- Every timeout is cleared on unmount.
- 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).
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-[var(--chart-1)] 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.