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 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' });
});
}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>
</>
);
}/** 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 };---
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 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' });
});
}/** 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 };<div class="flex flex-wrap items-center justify-center gap-3">
<button type="button" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border border-input bg-background hover:bg-accent hover:text-accent-foreground active:bg-pressed h-9 px-4" data-toast="Changes saved" data-toast-description="The preview updates within a minute.">
Save changes
</button>
<button type="button" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border border-input bg-background hover:bg-accent hover:text-accent-foreground active:bg-pressed h-9 px-4" data-toast="Deploy failed" data-toast-description="The build stopped at step 3. See the log." data-toast-variant="destructive">
Show an error
</button>
</div>
<section data-toaster popover="manual" aria-label="Notifications" class="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">
<ol class="m-0 flex list-none flex-col gap-2 p-0"></ol>
</section>
<div data-toaster-live role="status" aria-live="polite" class="sr-only"></div><script type="module" src="/caseroom/r/html/toast.js"></script>