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…

"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"

/**
 * 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.
 */
const KEYFRAMES = `@keyframes ts-in-bottom{from{transform:translateY(120%);opacity:0}}

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
- <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 viewporttoast() 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 errorsuseToast() throwing outside its provider turns a whole class of "the toast just never appeared" bugs into an immediate, named failure.

On This Page