Skip to content
    Style: Wireframe

    Components · Feedback

    Toast

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

    3.9% of the 2,073 sites in The UI Index have a alert (81 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/ui/r/toast.json
      Astro
      npx shadcn@latest add https://www.themes.dev/ui/r/astro/toast.json

      Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI 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 };