Skip to content
Product block

Toast Notification Stack

A corner stack whose live region is mounted before any message exists, errors announce assertively, and the auto-dismiss timer pauses on hover and on focus.

171 lineslucide-reactAdded 8 Aug 2026Updated 11 Sept 2026
  • toast
  • notification
  • snackbar
  • alert
  • live region

What's included

  • components/toast-stack.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add toast-stack

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Toast notifications

Fire one to see the stack. Hover or focus a toast to hold it open.

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/toast-stack.tsx
'use client'

/**
 * <ToastStack> — a corner notification stack with a working timer.
 *
 * Toasts are the component most often shipped inaccessible, because the
 * hard parts are invisible:
 *
 *  - The container is a permanent `aria-live` region that exists *before*
 *    any toast does. A live region added to the DOM at the same moment as
 *    its first message is usually not announced at all — the screen reader
 *    has nothing registered to watch.
 *  - Severity picks the politeness. Errors use `role="alert"`
 *    (assertive, interrupts); everything else is polite and waits its turn.
 *    Making a success message interrupt what someone is reading is rude,
 *    and making an error wait is dangerous.
 *  - Auto-dismiss pauses on hover *and* on focus, and stops entirely while
 *    the pointer is anywhere in the stack. A message that vanishes while
 *    being read is the same bug whether the reader uses a mouse or a
 *    keyboard.
 *  - Every toast has a real close button with a name. Auto-dismiss is a
 *    convenience, not a substitute — WCAG 2.2.1 wants a way to dismiss.
 *
 * The enter animation is gated behind `motion-safe:`; under `reduce` the
 * toast simply appears. The timer is unaffected — reduced motion is about
 * movement, not about taking things away.
 */

import * as React from 'react'
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react'

export type ToastVariant = 'success' | 'error' | 'warning' | 'info'

export interface Toast {
  id: number
  variant: ToastVariant
  title: string
  body?: string
}

export interface ToastStackProps {
  /** Milliseconds before a toast dismisses itself. 0 disables the timer. */
  duration?: number
  className?: string
}

const VARIANTS: Record<
  ToastVariant,
  { Icon: typeof Info; tone: string; ring: string }
> = {
  success: {
    Icon: CheckCircle2,
    tone: 'text-emerald-600 dark:text-emerald-400',
    ring: 'border-emerald-500/30',
  },
  error: { Icon: XCircle, tone: 'text-destructive', ring: 'border-destructive/30' },
  warning: {
    Icon: AlertTriangle,
    tone: 'text-amber-600 dark:text-amber-400',
    ring: 'border-amber-500/30',
  },
  info: { Icon: Info, tone: 'text-primary', ring: 'border-primary/30' },
}

const SAMPLES: Array<Omit<Toast, 'id'>> = [
  { variant: 'success', title: 'Deployment live', body: 'acme-web is serving from 14 regions.' },
  { variant: 'error', title: 'Build failed', body: 'Type error in app/page.tsx line 42.' },
  { variant: 'warning', title: 'Usage at 80%', body: 'You have used 8,000 of 10,000 requests.' },
  { variant: 'info', title: 'New version available', body: 'v2.4.0 is ready to install.' },
]

export function ToastStack({ duration = 5000, className = '' }: ToastStackProps) {
  const [toasts, setToasts] = React.useState<Toast[]>([])
  const [paused, setPaused] = React.useState(false)
  const nextId = React.useRef(0)

  const dismiss = React.useCallback((id: number) => {
    setToasts((list) => list.filter((t) => t.id !== id))
  }, [])

  function push(variant: ToastVariant) {
    const sample = SAMPLES.find((s) => s.variant === variant) ?? SAMPLES[0]!
    setToasts((list) => [...list, { ...sample, id: nextId.current++ }])
  }

  // One timer for the whole stack rather than one per toast: the pause is a
  // property of the stack (the pointer is over *it*), and a per-toast timer
  // would need the same paused flag threaded into every one of them.
  React.useEffect(() => {
    if (paused || duration <= 0 || toasts.length === 0) return
    const timer = setTimeout(() => {
      setToasts((list) => list.slice(1))
    }, duration)
    return () => clearTimeout(timer)
  }, [paused, duration, toasts])

  return (
    <div className={`relative min-h-96 p-10 ${className}`}>
      <div className="mx-auto max-w-md text-center">
        <h2 className="text-lg font-semibold">Toast notifications</h2>
        <p className="mt-1 text-sm text-muted-foreground">
          Fire one to see the stack. Hover or focus a toast to hold it open.
        </p>
        <div className="mt-5 flex flex-wrap justify-center gap-2">
          {(Object.keys(VARIANTS) as ToastVariant[]).map((variant) => (
            <button
              key={variant}
              type="button"
              onClick={() => push(variant)}
              className="rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-xs font-semibold capitalize transition-colors hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              {variant}
            </button>
          ))}
        </div>
      </div>

      {/*
        The live region is mounted always, empty or not. Creating it at the
        same time as its first message is the classic reason toasts are
        never announced.
      */}
      <div
        aria-live="polite"
        aria-label="Notifications"
        onMouseEnter={() => setPaused(true)}
        onMouseLeave={() => setPaused(false)}
        onFocusCapture={() => setPaused(true)}
        onBlurCapture={() => setPaused(false)}
        className="pointer-events-none absolute bottom-6 end-6 flex w-full max-w-sm flex-col gap-2"
      >
        {toasts.map((toast) => {
          const { Icon, tone, ring } = VARIANTS[toast.variant]
          return (
            <div
              key={toast.id}
              // Errors interrupt; everything else waits its turn.
              role={toast.variant === 'error' ? 'alert' : 'status'}
              className={`pointer-events-auto flex items-start gap-3 rounded-xl border bg-background/95 p-3.5 shadow-lg backdrop-blur motion-safe:animate-[toast-in_180ms_ease-out] ${ring}`}
            >
              <Icon aria-hidden className={`mt-0.5 h-4 w-4 shrink-0 ${tone}`} />
              <div className="min-w-0 flex-1">
                <p className="text-sm font-semibold">{toast.title}</p>
                {toast.body ? (
                  <p className="mt-0.5 text-xs text-muted-foreground">{toast.body}</p>
                ) : null}
              </div>
              <button
                type="button"
                onClick={() => dismiss(toast.id)}
                aria-label={`Dismiss: ${toast.title}`}
                className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                <X aria-hidden className="h-3.5 w-3.5" />
              </button>
            </div>
          )
        })}
      </div>

      {/* Keyframes travel with the component so it works wherever it lands. */}
      <style>{`
        @keyframes toast-in {
          from { opacity: 0; transform: translateY(8px) scale(0.98); }
          to   { opacity: 1; transform: none; }
        }
      `}</style>
    </div>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/toast-stack.tsx and import it where you need the section:

import { ToastStack } from '@/components/toast-stack'

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
durationMilliseconds before a toast dismisses itself. 0 disables the timer.number5000, className = ''
classNamestring—

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

toast-stack.html
<!--
  Toast Notification Stack — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<div class="relative min-h-96 p-10 ">
  <div class="mx-auto max-w-md text-center">
    <h2 class="text-lg font-semibold">Toast notifications</h2>
    <p class="mt-1 text-sm text-muted-foreground">Fire one to see the stack. Hover or focus a toast to hold it open.</p>
    <div class="mt-5 flex flex-wrap justify-center gap-2">
      <button type="button" class="rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-xs font-semibold capitalize transition-colors hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">success</button>
      <button type="button" class="rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-xs font-semibold capitalize transition-colors hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">error</button>
      <button type="button" class="rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-xs font-semibold capitalize transition-colors hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">warning</button>
      <button type="button" class="rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-xs font-semibold capitalize transition-colors hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">info</button>
    </div>
  </div>
  <div aria-live="polite" aria-label="Notifications" class="pointer-events-none absolute bottom-6 end-6 flex w-full max-w-sm flex-col gap-2"></div>
  <style>@keyframes toast-in {
          from { opacity: 0; transform: translateY(8px) scale(0.98); }
          to   { opacity: 1; transform: none; }
        }</style>
</div>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

6 more blocks in Notifications

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Notifications

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Notifications blocks

View category
Open the full page for this block

Notification Inbox Panel

The bell panel: filterable list with read state, the unread count in the accessible name rather than only in a badge, and machine-readable timestamps beside the human ones.

Notifications255 lines1 dep
Open the full page for this block

Notification Preferences Matrix

Per-event, per-channel switches as a real table, with required notifications locked and explained, and column headers that toggle a whole channel and report a mixed state honestly.

Notifications284 lines1 dep
Open the full page for this block

Push Permission Pre-Prompt

The soft ask before the browser dialog you only get one shot at — what will be sent, what will not, and a decline of the same weight as the yes.

Notifications222 lines1 dep
Open the full page for this block

Notification Digest

A day's notifications collapsed into one digest, grouped by what they are rather than when they arrived.

Notifications152 linesNo deps
Open the full page for this block

Digest Schedule Form

The control that turns notification volume down instead of off, which is the choice most people actually want.

Notifications175 linesNo deps
Open the full page for this block

Notification Channel List

Which channel carries which event, so the loud ones can be moved rather than muted entirely.

Notifications142 linesNo deps