# Toast (Caseroom component)

Short confirmations that announce themselves, pause while hovered and close on their own. Above open dialogs. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

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

## 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`
- HTML: https://www.themes.dev/caseroom/r/html/toast.html (with https://www.themes.dev/caseroom/r/html/caseroom.css)

Page: https://www.themes.dev/caseroom/components/toast/ · Rules for agents: https://www.themes.dev/caseroom/AGENT.md

## Code

### toast.astro

```astro
---
import { toast } from './toast.variants';
---

<section data-toaster popover="manual" aria-label="Notifications" class={toast.region}>
  <ol class={toast.list}></ol>
</section>
<div data-toaster-live role="status" aria-live="polite" class={toast.live}></div>

<script>
  import './toast.client';
</script>
```

### toast.client.ts

```tsx
// 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

```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

```tsx
/** 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 };
```
