Toast
FeedbackTransient notifications that land in the right live region — polite for the ordinary ones, assertive for the errors, and never the reverse.
Preview
Errors stay until dismissed. The rest clear after five seconds, and hovering one restarts its clock.
Controls
Notes
The whole component is one decision: which live region the message goes into. role="status" is polite and waits for the screen reader to finish its sentence; role="alert" interrupts mid-word. "Draft saved" interrupting someone reading is rude, and "Payment declined" waiting politely in a queue is worse than rude. The tone decides that, not preference.
Both regions are mounted for the life of the stack and only their children change. A live region created in the same tick as the text inside it is announced unreliably — that is the bug behind most "it works when I test it but not on a real machine" reports.
Auto-dismiss is a time limit under SC 2.2.1. Errors therefore never take one, every toast carries a close button, and hovering restarts the clock rather than merely pausing it — a restart errs toward giving the reader more time.
A toast is never the only place a message appears. It is gone in five seconds and it cannot be scrolled back to, so an error that only ever existed as a toast is an error nobody can act on.
The exit is driven by animationend rather than a second timer, so the node leaves when the animation actually finishes instead of racing it.
Every close button is named for its own toast. Four buttons all labelled "Close" is what a stack of them sounds like otherwise.
The stack caps at three. A queue that grows without limit stops being a notification and becomes a wall, and the older ones were going to expire unread anyway.
Source
ShowHide
"use client";
import { useRef, useState } from "react";
import { cn } from "@/lib/cn";
/**
* Transient notifications, and the whole component is really one decision:
* which live region the message lands in.
*
* role="status" is polite — it waits for the screen reader to finish what it
* is saying. role="alert" is assertive and interrupts mid-sentence. "Draft
* saved" interrupting someone reading a paragraph is rude; "Payment declined"
* waiting politely in a queue is worse than rude. Tone decides, not taste.
*
* Both regions are PERSISTENT in the DOM and only their children change. A
* live region created in the same tick as its text is announced unreliably —
* the common bug behind "it works in my testing but not on a real machine".
*
* Auto-dismiss is a time limit under SC 2.2.1, which is why errors never take
* one and every toast carries a close button regardless.
*/
const css = `
.toast-stack {
position: absolute;
z-index: 40;
display: flex;
flex-direction: column;
gap: 0.5rem;
inline-size: min(22rem, calc(100% - 2rem));
pointer-events: none;
}
.toast-stack[data-fixed="true"] { position: fixed; }
.toast-stack[data-position="top-right"] { top: 1rem; right: 1rem; }
.toast-stack[data-position="bottom-right"] { bottom: 1rem; right: 1rem; }
.toast-stack[data-position="bottom-center"] {
bottom: 1rem;
left: 50%;
translate: -50% 0;
}
/* Bottom stacks grow upward, so the newest sits nearest the edge it came from. */
.toast-stack[data-position^="bottom"] .toast-region { flex-direction: column-reverse; }
.toast-region {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@keyframes toast-in {
from { opacity: 0; translate: 0 var(--toast-from, -8px); scale: 0.97; }
to { opacity: 1; translate: 0 0; scale: 1; }
}
@keyframes toast-out {
from { opacity: 1; scale: 1; }
to { opacity: 0; scale: 0.97; }
}
.toast {
pointer-events: auto;
animation: toast-in 0.22s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.toast-stack[data-position^="bottom"] .toast { --toast-from: 8px; }
.toast[data-leaving="true"] { animation: toast-out 0.16s ease forwards; }
@media (prefers-reduced-motion: reduce) {
.toast,
.toast[data-leaving="true"] { animation: none; }
}
`;
export type ToastTone = "info" | "success" | "error";
export type ToastItem = {
id: string;
tone: ToastTone;
title: string;
description?: string;
actionLabel?: string;
};
const tones = {
info: { ring: "border-rule", mark: "text-ink-muted" },
success: { ring: "border-rule", mark: "text-[color:var(--positive)]" },
error: { ring: "border-accent/40", mark: "text-accent" },
} as const;
function ToneIcon({ tone }: { tone: ToastTone }) {
if (tone === "success") {
return (
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="8" stroke="currentColor" strokeWidth="1.5" />
<path
d="m6.4 10.2 2.4 2.4 4.8-4.8"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
if (tone === "error") {
return (
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M10 2.8 18 17H2L10 2.8Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path d="M10 8v3.4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<circle cx="10" cy="14.2" r="0.9" fill="currentColor" />
</svg>
);
}
return (
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="8" stroke="currentColor" strokeWidth="1.5" />
<path d="M10 9v5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<circle cx="10" cy="6.2" r="0.9" fill="currentColor" />
</svg>
);
}
export function Toast({
item,
onDismiss,
onAction,
onPointerEnter,
onPointerLeave,
}: {
item: ToastItem;
onDismiss: (item: ToastItem) => void;
onAction?: (item: ToastItem) => void;
onPointerEnter?: () => void;
onPointerLeave?: () => void;
}) {
const [leaving, setLeaving] = useState(false);
const tone = tones[item.tone];
return (
<div
className={cn(
"toast flex items-start gap-3 rounded-xl border bg-paper-raised px-4 py-3 shadow-lg",
tone.ring,
)}
data-leaving={leaving ? "true" : "false"}
// The exit has to finish before the node goes, so the removal is driven
// by the animation rather than by a second timer racing it.
onAnimationEnd={() => {
if (leaving) onDismiss(item);
}}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
>
<span className={cn("mt-px shrink-0", tone.mark)} aria-hidden="true">
<ToneIcon tone={item.tone} />
</span>
<div className="min-w-0 flex-1">
<p className="text-[14px] font-medium text-ink">{item.title}</p>
{item.description && (
<p className="mt-1 text-[13px] leading-relaxed text-ink-muted">{item.description}</p>
)}
{item.actionLabel && (
<button
type="button"
onClick={() => {
onAction?.(item);
setLeaving(true);
}}
className="mt-2 text-[13px] font-medium text-accent underline-offset-4 hover:underline"
>
{item.actionLabel}
</button>
)}
</div>
<button
type="button"
onClick={() => setLeaving(true)}
// Named, because "close" alone is four identical buttons in a stack.
aria-label={`Dismiss: ${item.title}`}
className="-mr-1 grid size-7 shrink-0 place-items-center rounded-full text-ink-faint transition-colors hover:bg-paper-sunken hover:text-ink"
>
<svg width="13" height="13" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="m6 6 8 8M14 6l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
);
}
export type ToastPosition = "top-right" | "bottom-right" | "bottom-center";
export function ToastStack({
toasts,
position = "top-right",
fixed = true,
onDismiss,
onAction,
onPointerEnter,
onPointerLeave,
}: {
toasts: ToastItem[];
position?: ToastPosition;
/** false anchors the stack to the nearest positioned ancestor instead. */
fixed?: boolean;
onDismiss: (item: ToastItem) => void;
onAction?: (item: ToastItem) => void;
onPointerEnter?: (item: ToastItem) => void;
onPointerLeave?: (item: ToastItem) => void;
}) {
const urgent = toasts.filter((t) => t.tone === "error");
const polite = toasts.filter((t) => t.tone !== "error");
const render = (item: ToastItem) => (
<Toast
key={item.id}
item={item}
onDismiss={onDismiss}
onAction={onAction}
onPointerEnter={() => onPointerEnter?.(item)}
onPointerLeave={() => onPointerLeave?.(item)}
/>
);
return (
<>
<style href="toast-stack" precedence="default">
{css}
</style>
<div className="toast-stack" data-position={position} data-fixed={fixed ? "true" : "false"}>
{/* Two regions, both always mounted. Errors interrupt; everything else
waits its turn. Neither region is created at the moment it gets its
text, which is what makes the announcement reliable. */}
<div role="alert" aria-live="assertive" aria-atomic="false" className="toast-region">
{urgent.map(render)}
</div>
<div role="status" aria-live="polite" aria-atomic="false" className="toast-region">
{polite.map(render)}
</div>
</div>
</>
);
}
const samples: Record<ToastTone, Omit<ToastItem, "id" | "actionLabel">> = {
info: { tone: "info", title: "Draft saved", description: "Last edit 2 seconds ago." },
success: {
tone: "success",
title: "Case study published",
description: "It is live at /work/cc-golf and in the sitemap.",
},
error: {
tone: "error",
title: "Could not send the message",
description: "Resend returned 503. Nothing was lost — try again.",
},
};
export default function Demo({
tone = "success",
position = "top-right",
action = true,
autoDismiss = true,
}: {
tone?: ToastTone;
position?: ToastPosition;
action?: boolean;
autoDismiss?: boolean;
}) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const nextId = useRef(0);
function clear(id: string) {
const timer = timers.current[id];
if (timer) clearTimeout(timer);
delete timers.current[id];
}
function arm(item: ToastItem) {
// Errors never take a time limit. Everything else gets five seconds.
if (!autoDismiss || item.tone === "error") return;
clear(item.id);
timers.current[item.id] = setTimeout(() => {
setToasts((current) => current.filter((t) => t.id !== item.id));
}, 5000);
}
function push() {
const item: ToastItem = {
...samples[tone],
id: `toast-${nextId.current++}`,
actionLabel: action ? (tone === "error" ? "Retry" : "Undo") : undefined,
};
setToasts((current) => [...current, item].slice(-3));
arm(item);
}
return (
<div className="relative flex min-h-72 w-full max-w-lg items-center justify-center rounded-xl border border-rule bg-paper-sunken p-6">
<div className="flex flex-col items-center gap-4">
<button
type="button"
onClick={push}
className="rounded-full bg-ink px-5 py-2.5 text-sm font-medium text-paper transition-opacity hover:opacity-85"
>
Show a toast
</button>
<p className="max-w-[19rem] text-center text-[12.5px] leading-relaxed text-ink-faint">
Errors stay until dismissed. The rest clear after five seconds, and
hovering one restarts its clock.
</p>
</div>
<ToastStack
toasts={toasts}
position={position}
// Anchored to this panel rather than the viewport, so the stack stays
// inside the preview. In a real app you want the default.
fixed={false}
onDismiss={(item) => {
clear(item.id);
setToasts((current) => current.filter((t) => t.id !== item.id));
}}
// Pause on hover is a restart rather than a resume: simpler, and it
// errs toward giving the reader more time rather than less.
onPointerEnter={(item) => clear(item.id)}
onPointerLeave={(item) => arm(item)}
/>
</div>
);
}
Next
File uploadNext step
Tell me where the business is losing time.
No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.