# Toast Stack

> Stacked toasts that fan out on hover, with auto-dismiss timers.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/toast-stack
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/toast-stack.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/toast-stack-demo.tsx"
"use client"

import { ToastStack, useToastStack, type ToastVariant } from "@/components/toast-stack"

const samples: { title: string; description: string; variant: ToastVariant }[] = [
  { title: "Deployment ready", description: "Production is now serving build #482.", variant: "success" },
  { title: "New comment", description: "Lena replied to your pull request.", variant: "default" },
  { title: "Usage at 90%", description: "You are close to your monthly limit.", variant: "warning" },
]

export default function ToastStackDemo() {
  const { toasts, push, dismiss } = useToastStack(6000)
  return (
    <>
      <button
        onClick={() => push(samples[toasts.length % samples.length])}
        className="h-10 rounded-lg border bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-muted"
      >
        Show toast
      </button>
      <ToastStack toasts={toasts} onDismiss={dismiss} />
    </>
  )
}
```

## Source

```tsx title="components/toast-stack.tsx"
"use client"

import * as React from "react"
import { CircleCheck, Info, TriangleAlert, X } from "lucide-react"

import { cn } from "@/lib/utils"

export type ToastVariant = "default" | "success" | "warning"

export type Toast = {
  id: number
  title: string
  description?: string
  variant?: ToastVariant
}

const icons = { default: Info, success: CircleCheck, warning: TriangleAlert }
const iconColors = {
  default: "text-sky-500",
  success: "text-emerald-500",
  warning: "text-amber-500",
}

/** Holds the toast queue and removes each toast after `duration` ms */
export function useToastStack(duration = 5000) {
  const [toasts, setToasts] = React.useState<Toast[]>([])
  const timers = React.useRef(new Map<number, ReturnType<typeof setTimeout>>())
  const nextId = React.useRef(0)

  const dismiss = React.useCallback((id: number) => {
    clearTimeout(timers.current.get(id))
    timers.current.delete(id)
    setToasts((all) => all.filter((t) => t.id !== id))
  }, [])

  const push = React.useCallback(
    (toast: Omit<Toast, "id">) => {
      const id = nextId.current++
      setToasts((all) => [...all, { ...toast, id }])
      timers.current.set(id, setTimeout(() => dismiss(id), duration))
    },
    [dismiss, duration]
  )

  React.useEffect(() => {
    const pending = timers.current
    return () => pending.forEach(clearTimeout)
  }, [])

  return { toasts, push, dismiss }
}

export interface ToastStackProps extends React.ComponentProps<"ol"> {
  toasts: Toast[]
  onDismiss: (id: number) => void
  /** How many toasts stay visible in the collapsed stack */
  visible?: number
}

export function ToastStack({ toasts, onDismiss, visible = 3, className, ...props }: ToastStackProps) {
  const [expanded, setExpanded] = React.useState(false)
  const ordered = [...toasts].reverse()

  return (
    <ol
      aria-live="polite"
      onMouseEnter={() => setExpanded(true)}
      onMouseLeave={() => setExpanded(false)}
      className={cn("fixed right-4 bottom-4 z-50 w-[min(360px,calc(100vw-2rem))]", className)}
      style={{ height: expanded ? ordered.length * 76 : 76 }}
      {...props}
    >
      {ordered.map((toast, index) => {
        const Icon = icons[toast.variant ?? "default"]
        const hidden = !expanded && index >= visible
        const offset = expanded ? index * 76 : index * 10
        const scale = expanded ? 1 : 1 - index * 0.05
        return (
          <li
            key={toast.id}
            className="absolute right-0 bottom-0 left-0 flex items-start gap-3 rounded-xl border bg-popover p-4 text-popover-foreground shadow-lg transition-all duration-300 ease-out animate-in fade-in slide-in-from-bottom-4"
            style={{
              transform: `translateY(-${offset}px) scale(${scale})`,
              opacity: hidden ? 0 : 1,
              zIndex: ordered.length - index,
              pointerEvents: hidden ? "none" : undefined,
            }}
          >
            <Icon className={cn("mt-0.5 size-4 shrink-0", iconColors[toast.variant ?? "default"])} />
            <div className="min-w-0 flex-1">
              <p className="text-sm font-medium">{toast.title}</p>
              {toast.description && (
                <p className="mt-0.5 truncate text-sm text-muted-foreground">{toast.description}</p>
              )}
            </div>
            <button
              onClick={() => onDismiss(toast.id)}
              aria-label="Dismiss"
              className="rounded-md p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
            >
              <X className="size-4" />
            </button>
          </li>
        )
      })}
    </ol>
  )
}
```
