Skip to content

Toast

Transient notifications in a single polite live region, with pinned errors and an optional undo action.

FeedbackintermediateFeaturednotificationsnackbarundolive-region

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

'use client'

import { Button } from '@/components/ui/button'
import { ToastProvider, useToast } from '@/components/ui/toast'
import { DemoRow, DemoStage } from './_kit'

function Triggers() {
  const { toast } = useToast()

  return (
    <DemoStage>
      <DemoRow label="Tones" description="Errors never auto-dismiss.">
        <Button variant="outline" onClick={() => toast({ title: 'Draft saved', tone: 'neutral' })}>
          Neutral
        </Button>
        <Button
          variant="outline"
          onClick={() =>
            toast({
              title: 'Deployment complete',
              description: 'v1.4.2 is live in EU West.',
              tone: 'success',
            })
          }
        >
          Success
        </Button>
        <Button
          variant="outline"
          onClick={() =>
            toast({
              title: 'Seat limit reached',
              description: '40 of 40 seats in use.',
              tone: 'warning',
            })
          }
        >
          Warning
        </Button>
        <Button
          variant="outline"
          onClick={() =>
            toast({
              title: 'Could not save changes',
              description: 'The workspace was modified by another member.',
              tone: 'danger',
            })
          }
        >
          Danger
        </Button>
      </DemoRow>

      <DemoRow label="With an action">
        <Button
          onClick={() =>
            toast({
              title: 'Member removed',
              description: 'Priya Raman no longer has access.',
              tone: 'neutral',
              action: {
                label: 'Undo',
                onClick: () => toast({ title: 'Removal undone', tone: 'success' }),
              },
            })
          }
        >
          Remove member
        </Button>
      </DemoRow>
    </DemoStage>
  )
}

export default function ToastDemo() {
  return (
    <ToastProvider>
      <Triggers />
    </ToastProvider>
  )
}

components/demos/toast.tsx

'use client'

import { Button } from '@/components/ui/button'
import { ToastProvider, useToast } from '@/components/ui/toast'
import { DemoRow, DemoStage } from './_kit'

function Triggers() {
  const { toast } = useToast()

  return (
    <DemoStage>
      <DemoRow label="Tones" description="Errors never auto-dismiss.">
        <Button variant="outline" onClick={() => toast({ title: 'Draft saved', tone: 'neutral' })}>
          Neutral
        </Button>
        <Button
          variant="outline"
          onClick={() =>
            toast({
              title: 'Deployment complete',
              description: 'v1.4.2 is live in EU West.',
              tone: 'success',
            })
          }
        >
          Success
        </Button>
        <Button
          variant="outline"
          onClick={() =>
            toast({
              title: 'Seat limit reached',
              description: '40 of 40 seats in use.',
              tone: 'warning',
            })
          }
        >
          Warning
        </Button>
        <Button
          variant="outline"
          onClick={() =>
            toast({
              title: 'Could not save changes',
              description: 'The workspace was modified by another member.',
              tone: 'danger',
            })
          }
        >
          Danger
        </Button>
      </DemoRow>

      <DemoRow label="With an action">
        <Button
          onClick={() =>
            toast({
              title: 'Member removed',
              description: 'Priya Raman no longer has access.',
              tone: 'neutral',
              action: {
                label: 'Undo',
                onClick: () => toast({ title: 'Removal undone', tone: 'success' }),
              },
            })
          }
        >
          Remove member
        </Button>
      </DemoRow>
    </DemoStage>
  )
}

export default function ToastDemo() {
  return (
    <ToastProvider>
      <Triggers />
    </ToastProvider>
  )
}

components/ui/toast.tsx

'use client'

import {
  createContext,
  useCallback,
  useContext,
  useMemo,
  useRef,
  useState,
  type ReactNode,
} from 'react'
import { X, CheckCircle2, AlertTriangle, Info, OctagonAlert } from 'lucide-react'
import { cn } from '@/lib/cn'
import { Portal } from './portal'

/**
 * Toast
 *
 * Transient, non-blocking messages. The viewport is a single polite live
 * region so a queue of three toasts is announced once each, in order, instead
 * of interrupting itself.
 *
 * Errors are rendered with `role="alert"` and never auto-dismiss: a message a
 * user may need to act on must not disappear on a timer.
 */
export type ToastTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'

export interface ToastOptions {
  title: string
  description?: string
  tone?: ToastTone
  /** Milliseconds before auto-dismiss. `0` pins the toast open. */
  duration?: number
  action?: { label: string; onClick: () => void }
}

interface ToastRecord extends ToastOptions {
  id: number
}

interface ToastContextValue {
  toast: (options: ToastOptions) => void
  dismiss: (id: number) => void
}

const ToastContext = createContext<ToastContextValue | null>(null)

export function useToast(): ToastContextValue {
  const context = useContext(ToastContext)
  if (!context) throw new Error('useToast must be used inside a <ToastProvider>')
  return context
}

const toneConfig = {
  neutral: { icon: Info, surface: 'border-line bg-surface-raised', accent: 'text-ink-muted' },
  success: {
    icon: CheckCircle2,
    surface: 'border-success-line bg-success-soft',
    accent: 'text-success',
  },
  warning: {
    icon: AlertTriangle,
    surface: 'border-warning-line bg-warning-soft',
    accent: 'text-warning',
  },
  danger: {
    icon: OctagonAlert,
    surface: 'border-danger-line bg-danger-soft',
    accent: 'text-danger',
  },
  info: { icon: Info, surface: 'border-info-line bg-info-soft', accent: 'text-info' },
} as const

export function ToastProvider({ children }: { children: ReactNode }) {
  const [toasts, setToasts] = useState<ToastRecord[]>([])
  const counter = useRef(0)
  const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>())

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

  const toast = useCallback(
    (options: ToastOptions) => {
      counter.current += 1
      const id = counter.current
      const tone = options.tone ?? 'neutral'
      const duration = options.duration ?? (tone === 'danger' ? 0 : 4500)
      setToasts((current) => [...current.slice(-2), { ...options, id, tone }])
      if (duration > 0) {
        timers.current.set(
          id,
          setTimeout(() => dismiss(id), duration),
        )
      }
    },
    [dismiss],
  )

  const value = useMemo(() => ({ toast, dismiss }), [toast, dismiss])

  return (
    <ToastContext.Provider value={value}>
      {children}
      <Portal>
        <div
          role="region"
          aria-label="Notifications"
          className="pointer-events-none fixed inset-x-0 bottom-0 z-[110] flex flex-col items-center gap-2 p-4 sm:inset-x-auto sm:right-0 sm:items-end"
        >
          <div
            aria-live="polite"
            aria-atomic="false"
            className="flex w-full flex-col gap-2 sm:w-auto"
          >
            {toasts.map((item) => {
              const config = toneConfig[item.tone ?? 'neutral']
              const Icon = config.icon
              return (
                <div
                  key={item.id}
                  role={item.tone === 'danger' ? 'alert' : 'status'}
                  className={cn(
                    'animate-scale-in pointer-events-auto flex w-full items-start gap-3 rounded-lg border p-3 shadow-lg sm:w-88',
                    config.surface,
                  )}
                >
                  <Icon
                    className={cn('mt-0.5 size-4 shrink-0', config.accent)}
                    aria-hidden="true"
                  />
                  <div className="min-w-0 flex-1">
                    <p className="text-sm font-semibold text-ink-strong">{item.title}</p>
                    {item.description ? (
                      <p className="mt-0.5 text-xs text-ink-muted">{item.description}</p>
                    ) : null}
                    {item.action ? (
                      <button
                        type="button"
                        onClick={() => {
                          item.action?.onClick()
                          dismiss(item.id)
                        }}
                        className="mt-2 text-xs font-semibold text-accent underline underline-offset-4"
                      >
                        {item.action.label}
                      </button>
                    ) : null}
                  </div>
                  <button
                    type="button"
                    onClick={() => dismiss(item.id)}
                    className="-m-1 shrink-0 rounded-sm p-1 text-ink-subtle transition-colors hover:bg-surface-sunken hover:text-ink"
                  >
                    <X className="size-3.5" aria-hidden="true" />
                    <span className="sr-only">Dismiss notification</span>
                  </button>
                </div>
              )
            })}
          </div>
        </div>
      </Portal>
    </ToastContext.Provider>
  )
}

components/ui/portal.tsx

'use client'

import { useSyncExternalStore, type ReactNode } from 'react'
import { createPortal } from 'react-dom'

/**
 * Portal
 *
 * Renders children into `document.body` so overlays escape any ancestor that
 * creates a stacking or containing block — a transformed card, a `contain`ed
 * preview frame, an `overflow: hidden` shell.
 *
 * Returns `null` until mounted, which keeps the server and first client render
 * identical and avoids a hydration mismatch.
 */
const subscribe = () => () => {}

export function Portal({ children }: { children: ReactNode }) {
  // `useSyncExternalStore` is the idiomatic "am I hydrated" check: the server
  // snapshot is false and the client snapshot is true, with no effect and no
  // extra render pass.
  const mounted = useSyncExternalStore(
    subscribe,
    () => true,
    () => false,
  )

  if (!mounted) return null
  return createPortal(children, document.body)
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For confirmation of something the user just did, when they should not have to stop reading. Provided through a context provider so any component can raise one without prop drilling.

  • Errors never auto-dismiss — a message a user may need to act on must not vanish on a timer.
  • The queue is capped at three; older toasts are dropped rather than stacking off-screen.
  • An undo action is worth more than a confirmation dialog for reversible operations.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Neutral
  • Success
  • Warning
  • Danger — pinned
  • Info
  • With action

Accessibility

Live region
One polite region wraps the whole stack, so three toasts are announced in order instead of interrupting each other.
Roles
Danger toasts use `role="alert"`; all others use `role="status"`.
Dismissal
Every toast has a real, labelled close button — dismissal never depends on the timer.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.