Skip to content
Caseroom

Components · Feedback

Toast

Short confirmations that announce themselves, pause while hovered and close on their own. Above open dialogs.

3.8% of the 2,062 sites in The UI Index have a alert (78 sites).

    Both previews render the same markup; the HTML tab below is that markup without a framework.

    Install

    React
    npx shadcn@latest add https://www.themes.dev/caseroom/r/toast.json
    Astro
    npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/toast.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.

    toast.client.ts
    // Toast behaviour, shared by the Astro, React and HTML versions. Put one <Toaster /> on the page, then call
    // toast({ title, description?, variant?, duration? }), or give a button data-toast="Title" (and
    // data-toast-description="…") to show one on click without writing script.
    import { CLOSE_PATH, toast as cls, type ToastOptions } from './toast.variants';
    
    declare global {
      interface Window {
        __caseroomToast?: boolean;
      }
    }
    
    /** The page's toaster: the last one that is rendered (a toaster inside a hidden panel can't show anything). */
    const parts = () => {
      const all = [...document.querySelectorAll<HTMLElement>('[data-toaster]')];
      const region = all.reverse().find((r) => !r.parentElement?.closest('[hidden], [inert]')) ?? null;
      return { region, list: region?.querySelector<HTMLElement>('ol') ?? null, live: region?.nextElementSibling?.matches('[data-toaster-live]') ? (region.nextElementSibling as HTMLElement) : null };
    };
    
    function dismiss(li: HTMLElement) {
      if (li.dataset.closing !== undefined) return;
      li.dataset.closing = '';
      const done = () => {
        const list = li.parentElement;
        const region = li.closest<HTMLElement>('[data-toaster]');
        li.remove();
        if (list && !list.children.length && region?.matches(':popover-open')) region.hidePopover();
      };
      matchMedia('(prefers-reduced-motion: reduce)').matches ? done() : setTimeout(done, 150);
    }
    
    export function toast({ title, description, variant = 'default', duration = 5000 }: ToastOptions) {
      const { region, list, live } = parts();
      if (!region || !list) throw new Error('caseroom toast: put a <Toaster /> on the page');
      const li = document.createElement('li');
      li.className = `${cls.item}${variant === 'destructive' ? ` ${cls.destructive}` : ''}`;
      const t = document.createElement('p');
      t.className = cls.title;
      t.dataset.toastTitle = '';
      t.textContent = title;
      li.append(t);
      if (description) {
        const d = document.createElement('p');
        d.className = cls.description;
        d.textContent = description;
        li.append(d);
      }
      const close = document.createElement('button');
      close.type = 'button';
      close.className = cls.close;
      close.setAttribute('aria-label', `Dismiss: ${title}`);
      close.innerHTML = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true" class="size-4"><path d="${CLOSE_PATH}"/></svg>`;
      close.addEventListener('click', () => dismiss(li));
      li.append(close);
      list.append(li);
      if (!region.matches(':popover-open')) region.showPopover();
      // Announce: clear, then write on the next frame, so the same text twice is read twice.
      if (live) {
        live.textContent = '';
        requestAnimationFrame(() => (live.textContent = description ? `${title}. ${description}` : title));
      }
      // Close after `duration`, but not while the pointer or focus is on the toast.
      let left = duration;
      let since = Date.now();
      let timer = window.setTimeout(() => dismiss(li), left);
      const pause = () => {
        clearTimeout(timer);
        left -= Date.now() - since;
      };
      const resume = () => {
        if (li.matches(':hover, :focus-within')) return;
        since = Date.now();
        timer = window.setTimeout(() => dismiss(li), Math.max(1000, left));
      };
      li.addEventListener('pointerenter', pause);
      li.addEventListener('focusin', pause);
      li.addEventListener('pointerleave', resume);
      li.addEventListener('focusout', () => setTimeout(resume));
      return { dismiss: () => dismiss(li) };
    }
    
    if (typeof window !== 'undefined' && !window.__caseroomToast) {
      window.__caseroomToast = true;
      document.addEventListener('click', (e) => {
        const b = (e.target as Element).closest<HTMLElement>('[data-toast]');
        if (b && !(b as HTMLButtonElement).disabled) toast({ title: b.dataset.toast!, description: b.dataset.toastDescription, variant: b.dataset.toastVariant === 'destructive' ? 'destructive' : 'default' });
      });
    }
    toast.tsx
    import './toast.client';
    import { toast as cls } from './toast.variants';
    
    export { toast } from './toast.client';
    
    /** The toast region: put it once on the page, then call toast({ title, description? }). */
    export function Toaster() {
      return (
        <>
          <section data-toaster="" popover="manual" aria-label="Notifications" className={cls.region}>
            <ol className={cls.list}></ol>
          </section>
          <div data-toaster-live="" role="status" aria-live="polite" className={cls.live}></div>
        </>
      );
    }
    toast.variants.ts
    /** Toasts: short confirmations at the bottom right, in the top layer (above an open dialog). The list is a manual
     *  popover shown while it has toasts; a separate, always-present live region announces each one. A toast slides up
     *  4px and fades in; with reduced motion it only fades. It closes after 5 seconds, paused while the pointer or focus
     *  is on it (WCAG 2.2.1), and has its own close button. */
    export const toast = {
      region: 'pointer-events-none fixed m-0 [inset:auto] right-4 bottom-4 left-4 w-auto overflow-visible bg-transparent p-0 sm:left-auto sm:w-90',
      list: 'm-0 flex list-none flex-col gap-2 p-0',
      item: 'pointer-events-auto relative grid grid-cols-[1fr_auto] items-start gap-x-3 rounded-lg border bg-popover py-3 pr-2 pl-4 text-popover-foreground shadow-lg dark:shadow-[0_12px_32px_rgb(0_0_0/0.65)] transition-[opacity,translate] duration-200 ease-out starting:translate-y-1 starting:opacity-0 data-[closing]:opacity-0 data-[closing]:duration-150 motion-reduce:starting:translate-y-0',
      destructive: 'border-destructive/40 [&_[data-toast-title]]:text-destructive',
      title: 'text-sm font-medium',
      description: 'col-start-1 mt-0.5 text-sm text-pretty text-muted-foreground',
      close: 'col-start-2 row-span-2 row-start-1 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed',
      live: 'sr-only',
    };
    
    export const CLOSE_PATH = 'M4 4l8 8M12 4l-8 8';
    export type ToastOptions = { title: string; description?: string; variant?: 'default' | 'destructive'; duration?: number };