Toast
Brief, auto-dismissing notifications with an imperative toast() API, built on the Radix Toast primitive.
Preview
Shared files4 items
Pebble redesign4 items
Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
npx shadcn@latest add https://pebbleui.com/r/toast.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| toast(options) | (options) => { id, dismiss, update } | - | Show a toast from anywhere; needs a mounted <Toaster />. |
| title / description | ReactNode | - | toast() options: heading and body text. |
| variant | "default" | "success" | "warning" | "danger" | "default" | toast() option: status accent; warning/danger announce assertively. |
| duration | number | 5000 | toast() option or Toaster prop: ms before auto-dismiss. |
| action | { label, altText, onClick } | - | toast() option: one action button; altText is required. |
| progress | number | - | toast() option, 0-100: shows a progress bar and a spinner until it reaches 100. |
| stacked | boolean | true | Toaster and ToastViewport: pile toasts into a depth stack that fans out on hover or focus. |
| expanded | boolean | false | Toaster and ToastViewport: keep the stack fanned out. |
| ToastProgress | { value: number; label?: string } | - | Progress bar part for custom toasts; value is 0-100, label names the bar (default "Progress"). |
| toast.dismiss(id?) | (id?: string) => void | - | Close one toast, or all when called without an id. |
| swipeDirection | "right" | "left" | "right" | Toaster: swipe-to-dismiss direction. |
| label | string | "Notifications ({hotkey})" | Toaster: viewport region label. |
| hotkey | string[] | ["F8"] | Toaster: keys that move focus to the toasts. |
Build notes
Built on the Radix Toast primitive (provider, viewport, root, title, description, action, close), which supplies the timer with pause-on-hover/focus/blur, the F8 hotkey, Escape, the live-region announcer and swipe gesture tracking. The imperative API is a tiny module-level store: an immutable array of records `{ id, open, ...options }`, a `Set` of listeners, and `emit()` that swaps the array and notifies. `useToast()` reads it with `useSyncExternalStore`, using a constant empty array as the server snapshot so SSR renders no toasts and hydration matches. Nothing touches `window` at module scope; timers are only created inside `toast()`/`dismiss()`. Closing sets `open: false` (so Radix's presence can play the exit keyframe) and schedules removal from the store 1 s later. Opening a fourth toast closes the oldest open one. Swipe uses Radix's CSS variables: `translate-x-(--radix-toast-swipe-move-x)` while `data-swipe=move`, back to 0 on `cancel`, and `--radix-toast-swipe-end-x` on `end`. Two keyframes ship in the registry item's `css`: `toast-in` (`transform` translateY 100%, opacity 0 to rest; 200 ms ease-out) and `toast-out` (opacity 1 to 0; 150 ms ease-in). `translate` is in the toast's transition list (`--motion-base`, `ease-move`; off while `data-swipe=move` and under reduced motion), so a cancelled swipe slides back to 0 instead of jumping; on `end` Radix sets the end offset to the last move offset, so nothing slides. `toast-out` animates opacity only, so a swiped toast keeps its `translate` at the swipe end offset while it fades, and one keyframe serves both close paths regardless of direction. Only the x-axis swipe variables are wired, so `swipeDirection` is typed to left and right: a missing y variable would invalidate Tailwind's shared `translate` declaration. The accent bar is a `::before` reading `--toast-accent`, which each status variant sets to its colour token; the default variant leaves it transparent.
**Pebble (2026-11-03).** The card is a raised pebble (`rounded-xl`, `shadow-raised-lg`, hairline ring, top highlight) and the accent bar is gone: status colour now lives in `ToastIcon`, a 20 px disc tinted with `color-mix` of `--toast-accent` holding a glyph (or a spinner when `loading`). `ToastProgress` is a recessed 6 px track with a foreground fill and tabular percentage, exposed as `role=progressbar`; a `progress` option below 100 keeps the toast open. The deck follows DESIGN.md Stacks: `ToastViewport` measures each open toast's natural height (the tallest in-flow child's `offsetHeight` plus its margins, or for a `flex-col` card the sum of the children plus row gaps, plus the card's padding and borders; no in-flow child may stretch to the card's height, since its `offsetHeight` would follow the forced height; `offsetTop` is not used because the self-centred `ToastAction` moves with the forced height) in a layout effect re-run by a MutationObserver and ResizeObserver, then writes inline `transform`, `height` and `z-index`. Toasts behind the front shift up 12 px and scale down 0.05 per depth (capped at two), take the front's height and fade their content, and the fourth onward hides. Every value is a `calc()` over `--toast-expanded`, which CSS sets to 1 on hover, `focus-within` or `data-expanded`, so fanning out is a pure CSS transition (`--motion-base`, `ease-move`) with no re-render. When nothing can be measured (empty, or jsdom) the viewport stays a plain flex list. `toast-in` now runs on `--motion-slow`/`ease-enter` and `toast-out` on `--motion-fast`/`ease-exit`; the inline transform is safe because a running keyframe overrides it until it ends. The close button is hidden until hover or focus on pointer devices. `ToastAction` is a secondary pebble key with a 0.97 press.
<!-- PEBBLE-LICENSE -->View source
registry/feedback/toast/toast.tsx
"use client";
/* PEBBLE-LICENSE
Built by Pebble. Component: toast.
First-party license: MIT
The following first-party terms apply only to original Pebble material.
MIT License
Copyright (c) 2026 Praxys Solutions LLC (Pebble)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */
import * as React from "react";
import { Toast as ToastPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------- */
/* Parts */
/* -------------------------------------------------------------------------- */
// Pebble: every toast is the same raised card; the variant only sets an accent
// pair that the icon disc (and anything else that wants it) reads.
const variants = {
default:
"[--toast-accent:var(--color-foreground)] [--toast-accent-strong:var(--color-foreground)]",
success:
"[--toast-accent:var(--color-success)] [--toast-accent-strong:var(--color-success-strong)]",
warning:
"[--toast-accent:var(--color-warning)] [--toast-accent-strong:var(--color-warning-strong)]",
danger: "[--toast-accent:var(--color-danger)] [--toast-accent-strong:var(--color-danger-strong)]",
} as const;
export type ToastVariant = keyof typeof variants;
// Warnings and errors are announced assertively ("foreground"); the rest wait
// for the screen reader to finish what it is saying ("background").
const announce = {
default: "background",
success: "background",
warning: "foreground",
danger: "foreground",
} as const;
// Stack geometry (DESIGN.md "Stacks"): each toast behind the front one peeks
// 12 px above it and shrinks 5 % per step; hover or focus fans them out.
const PEEK = 12;
const SCALE_STEP = 0.05;
const GAP = 8;
const VISIBLE = 3;
export function ToastProvider(props: React.ComponentProps<typeof ToastPrimitive.Provider>) {
return <ToastPrimitive.Provider {...props} />;
}
const px = (value: string) => parseFloat(value || "0") || 0;
// Height of a toast's own content, ignoring any height the stack forced on it.
// A row toast (the default, `items-start`) is as tall as its tallest in-flow
// child (with margins); a column toast (`flex-col`) is the sum of its children
// plus the row gaps. Padding and borders are added on top. Child offsetTop is
// not used: a self-centred child (ToastAction) moves with the forced height.
// Assumes no in-flow child stretches to the card's height (`items-stretch`,
// `self-stretch`), since its offsetHeight would follow the forced height.
function naturalHeight(li: HTMLElement) {
const style = getComputedStyle(li);
const column = style.flexDirection.startsWith("column");
let content = 0;
let count = 0;
for (const child of Array.from(li.children) as HTMLElement[]) {
const own = getComputedStyle(child);
if (own.position === "absolute" || own.display === "none") continue;
const h = child.offsetHeight + px(own.marginTop) + px(own.marginBottom);
content = column ? content + h : Math.max(content, h);
count += 1;
}
if (content === 0) return 0;
if (column && count > 1) content += px(style.rowGap) * (count - 1);
return (
content +
px(style.paddingTop) +
px(style.paddingBottom) +
px(style.borderTopWidth) +
px(style.borderBottomWidth)
);
}
// Inline geometry layoutStack writes onto each toast; cleared when unstacking.
function clearStack(ol: HTMLElement) {
ol.removeAttribute("data-stacked");
ol.style.height = "";
for (const el of Array.from(ol.children) as HTMLElement[]) {
if (el.dataset.slot !== "toast") continue;
el.removeAttribute("data-front");
el.removeAttribute("data-behind");
el.removeAttribute("data-overflow");
el.style.zIndex = "";
el.style.transform = "";
el.style.height = "";
}
}
// Writes the stack geometry onto each open toast as inline styles. The only
// live input is `--toast-expanded` (0 or 1, set by CSS on hover/focus), so
// expanding and collapsing never needs JavaScript.
function layoutStack(ol: HTMLElement) {
const items = (Array.from(ol.children) as HTMLElement[]).filter(
(el) => el.dataset.slot === "toast",
);
const open = items.filter((el) => el.dataset.state === "open").reverse();
const heights = open.map(naturalHeight);
if (heights.every((h) => h === 0)) {
// Empty, or nothing measurable (no layout engine): stay a plain list.
ol.removeAttribute("data-stacked");
ol.style.height = "";
return;
}
ol.setAttribute("data-stacked", "");
const front = heights[0] ?? 0;
const e = "var(--toast-expanded, 0)";
let offset = 0;
open.forEach((el, i) => {
const h = heights[i]!;
el.toggleAttribute("data-front", i === 0);
el.toggleAttribute("data-behind", i > 0);
el.toggleAttribute("data-overflow", i >= VISIBLE);
el.style.zIndex = String(open.length - i);
el.style.transform =
i === 0
? ""
: `translateY(calc(-1 * ((1 - ${e}) * ${PEEK * Math.min(i, VISIBLE - 1)}px + ${e} * ${offset}px))) scale(calc(1 - (1 - ${e}) * ${SCALE_STEP * Math.min(i, VISIBLE - 1)}))`;
el.style.height = i === 0 ? "" : `calc(${front}px + ${e} * ${h - front}px)`;
offset += h + GAP;
});
const collapsed = open.length ? front + PEEK * Math.min(open.length - 1, VISIBLE - 1) : 0;
const expanded = Math.max(0, offset - GAP);
ol.style.height = `calc(${collapsed}px + ${e} * ${expanded - collapsed}px + 2 * var(--toast-edge, 0px))`;
}
export type ToastViewportProps = React.ComponentProps<typeof ToastPrimitive.Viewport> & {
/** Pile toasts into a depth stack that fans out on hover/focus. Default `true`. */
stacked?: boolean;
/** Keep the stack fanned out. */
expanded?: boolean;
};
export function ToastViewport({
className,
stacked = true,
expanded = false,
ref,
...props
}: ToastViewportProps) {
const local = React.useRef<HTMLOListElement | null>(null);
const setRef = React.useCallback(
(node: HTMLOListElement | null) => {
local.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
React.useLayoutEffect(() => {
const ol = local.current;
if (!stacked || !ol) return;
let frame = 0;
const raf =
typeof requestAnimationFrame === "function"
? requestAnimationFrame
: (cb: () => void) => setTimeout(cb, 16) as unknown as number;
const caf = typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : clearTimeout;
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => run());
const watched = new WeakSet<Element>();
function watch() {
for (const child of Array.from(ol!.children)) {
if (watched.has(child)) continue;
watched.add(child);
resize?.observe(child);
}
}
function run() {
caf(frame);
frame = raf(() => {
layoutStack(ol!);
watch();
});
}
layoutStack(ol);
const mutations = new MutationObserver(run);
mutations.observe(ol, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ["data-state"],
});
watch();
return () => {
caf(frame);
mutations.disconnect();
resize?.disconnect();
clearStack(ol);
};
}, [stacked]);
return (
<ToastPrimitive.Viewport
ref={setRef}
data-slot="toast-viewport"
data-expanded={expanded ? "" : undefined}
className={cn(
"fixed right-0 bottom-0 z-100 m-0 flex max-h-screen w-full list-none flex-col gap-2 p-(--toast-edge) outline-none [--toast-edge:1rem] sm:max-w-sm",
// 0 at rest, 1 while the pointer or focus is inside (or when pinned open).
"[--toast-expanded:0] focus-within:[--toast-expanded:1] hover:[--toast-expanded:1] data-expanded:[--toast-expanded:1]",
"data-stacked:box-border data-stacked:transition-[height] data-stacked:duration-(--motion-base) data-stacked:ease-move motion-reduce:transition-none",
className,
)}
{...props}
/>
);
}
/**
* In a stacked viewport the toast's height is measured from its children, so
* a `className` may switch to `flex-col` but should not stretch children to
* the card's height (`items-stretch`, or `self-stretch` on a child).
*/
export type ToastProps = React.ComponentProps<typeof ToastPrimitive.Root> & {
variant?: ToastVariant;
};
export function Toast({ variant = "default", type, className, ...props }: ToastProps) {
return (
<ToastPrimitive.Root
data-slot="toast"
data-variant={variant}
type={type ?? announce[variant]}
className={cn(
"group/toast pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden rounded-xl bg-card p-3.5 pr-10 text-[13px] text-card-foreground",
"shadow-raised-lg ring-1 inset-shadow-highlight ring-hairline",
"forced-colors:border forced-colors:border-[CanvasText]",
// Stacked: pinned to the viewport's bottom edge; JS writes the depth
// transform and height, CSS animates them.
"in-data-stacked:absolute in-data-stacked:inset-x-(--toast-edge) in-data-stacked:bottom-(--toast-edge) in-data-stacked:w-auto in-data-stacked:origin-top",
"transition-[transform,translate,height,opacity,box-shadow] duration-(--motion-base) ease-move motion-reduce:transition-none",
// Behind the front toast the content fades so only the card edge peeks.
"*:transition-opacity *:duration-(--motion-fast) data-overflow:opacity-(--toast-expanded) data-behind:[&>:not([data-slot=toast-close])]:opacity-(--toast-expanded)",
"data-behind:shadow-raised-sm",
// Swipe: Radix writes the drag offset into CSS variables (the `translate`
// property, so it composes with the stack transform).
"data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=move]:transition-none",
"motion-safe:data-[state=open]:animate-[toast-in_var(--motion-slow)_var(--motion-enter)]",
// Exit is opacity only, so it stays under reduced motion.
"data-[state=closed]:animate-[toast-out_var(--motion-fast)_var(--motion-exit)_forwards]",
variants[variant],
className,
)}
{...props}
/>
);
}
const icons = {
success: <path d="m5.5 8.25 1.75 1.75 3.25-4" />,
warning: (
<>
<path d="M8 4.75v3.5" />
<path d="M8 11h.01" />
</>
),
danger: <path d="m5.75 5.75 4.5 4.5m0-4.5-4.5 4.5" />,
default: <path d="M8 7.5V11M8 5h.01" />,
} as const;
export type ToastIconProps = React.ComponentProps<"span"> & {
variant?: ToastVariant;
/** Show a spinner instead of the status glyph. */
loading?: boolean;
};
/** Status glyph in a tinted disc, coloured by the toast's accent. */
export function ToastIcon({ variant = "default", loading, className, ...props }: ToastIconProps) {
return (
<span
aria-hidden="true"
data-slot="toast-icon"
className={cn(
"mt-px grid size-5 shrink-0 place-items-center rounded-full text-(--toast-accent-strong)",
"bg-[color-mix(in_oklab,var(--toast-accent)_14%,transparent)] ring-1 ring-[color-mix(in_oklab,var(--toast-accent)_22%,transparent)]",
loading && "bg-transparent ring-0",
className,
)}
{...props}
>
{loading ? (
<svg viewBox="0 0 16 16" fill="none" className="size-4 motion-safe:animate-spin">
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.18" strokeWidth="2" />
<path
d="M14 8a6 6 0 0 0-6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
) : (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
>
{icons[variant]}
</svg>
)}
</span>
);
}
export function ToastTitle({
className,
...props
}: React.ComponentProps<typeof ToastPrimitive.Title>) {
return (
<ToastPrimitive.Title
data-slot="toast-title"
className={cn("leading-5 font-medium text-foreground", className)}
{...props}
/>
);
}
export function ToastDescription({
className,
...props
}: React.ComponentProps<typeof ToastPrimitive.Description>) {
return (
<ToastPrimitive.Description
data-slot="toast-description"
className={cn("leading-5 text-muted-foreground", className)}
{...props}
/>
);
}
export type ToastProgressProps = React.ComponentProps<"div"> & {
/** 0–100. */
value: number;
/** Accessible name for the bar. Default "Progress". */
label?: string;
};
/** Recessed track with an ink fill and a tabular percentage. */
export function ToastProgress({
value,
label = "Progress",
className,
...props
}: ToastProgressProps) {
const pct = Number.isNaN(value) ? 0 : Math.round(Math.min(100, Math.max(0, value)));
return (
<div
data-slot="toast-progress"
className={cn("mt-2 flex items-center gap-2.5", className)}
{...props}
>
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted ring-1 inset-shadow-xs ring-hairline"
>
<div
className="h-full rounded-full bg-foreground transition-[width] duration-(--motion-slow) ease-move motion-reduce:transition-none"
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-8 text-right text-[12px] text-muted-foreground tabular-nums">{pct}%</span>
</div>
);
}
export function ToastAction({
className,
...props
}: React.ComponentProps<typeof ToastPrimitive.Action>) {
return (
<ToastPrimitive.Action
data-slot="toast-action"
className={cn(
// A small secondary pebble key.
"inline-flex h-7 shrink-0 cursor-pointer items-center justify-center self-center rounded-md bg-card px-2.5 text-[13px] font-medium text-foreground select-none",
"shadow-raised-xs ring-1 inset-shadow-highlight ring-hairline",
"transition-[background-color,transform,box-shadow] duration-(--motion-fast) ease-enter hover:bg-muted",
"active:translate-y-px active:scale-[0.97] active:shadow-none motion-reduce:transition-none motion-reduce:active:translate-y-0 motion-reduce:active:scale-100",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card focus-visible:outline-none",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export function ToastClose({
className,
...props
}: React.ComponentProps<typeof ToastPrimitive.Close>) {
return (
<ToastPrimitive.Close
data-slot="toast-close"
aria-label="Dismiss notification"
className={cn(
"absolute top-2.5 right-2.5 inline-flex size-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground",
"transition-[opacity,background-color,color] duration-(--motion-fast) ease-enter hover:bg-muted hover:text-foreground motion-reduce:transition-none",
// Revealed on hover/focus where hover exists; always shown on touch.
"[@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within/toast:opacity-100 [@media(hover:hover)]:group-hover/toast:opacity-100",
"[@media(hover:none)]:group-data-behind/toast:opacity-(--toast-expanded)",
"focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
className,
)}
{...props}
>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
aria-hidden="true"
className="size-3.5"
>
<path d="m4 4 8 8M12 4l-8 8" />
</svg>
</ToastPrimitive.Close>
);
}
/* -------------------------------------------------------------------------- */
/* Imperative API */
/* -------------------------------------------------------------------------- */
export type ToastOptions = {
title?: React.ReactNode;
description?: React.ReactNode;
variant?: ToastVariant;
/** 0–100: shows a progress bar and a spinner in place of the status glyph. */
progress?: number;
/** Milliseconds before auto-dismiss. Defaults to the Toaster's `duration`. */
duration?: number;
action?: {
label: React.ReactNode;
/** Tells screen-reader users how to do the same thing without the toast. */
altText: string;
onClick: () => void;
};
};
export type ToastRecord = ToastOptions & { id: string; open: boolean };
// Keep a few toasts on screen; older ones close as new ones arrive.
const LIMIT = 3;
// Time a closed toast stays in the store so its exit animation can finish.
const REMOVE_DELAY = 1000;
const EMPTY: ToastRecord[] = [];
// Module-level store. Nothing here touches window or document, so importing
// it on the server is safe; timers are only set from toast()/dismiss() calls.
let records: ToastRecord[] = EMPTY;
let counter = 0;
const listeners = new Set<() => void>();
function emit(next: ToastRecord[]) {
records = next;
listeners.forEach((listener) => listener());
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
// One pending removal per toast, so repeated dismissals never stack timers.
const removalTimers = new Map<string, ReturnType<typeof setTimeout>>();
function scheduleRemoval(ids: string[]) {
for (const id of ids) {
clearTimeout(removalTimers.get(id));
removalTimers.set(
id,
setTimeout(() => {
removalTimers.delete(id);
emit(records.filter((r) => r.id !== id || r.open));
}, REMOVE_DELAY),
);
}
}
function dismiss(id?: string) {
const closing = records.filter((r) => r.open && (id === undefined || r.id === id));
if (closing.length === 0) return;
const ids = closing.map((r) => r.id);
emit(records.map((r) => (ids.includes(r.id) ? { ...r, open: false } : r)));
scheduleRemoval(ids);
}
function show(options: ToastOptions) {
counter = (counter + 1) % Number.MAX_SAFE_INTEGER;
const id = `toast-${counter}`;
const next = [...records, { ...options, id, open: true }];
const open = next.filter((r) => r.open);
const overflow = open.slice(0, Math.max(0, open.length - LIMIT)).map((r) => r.id);
emit(next.map((r) => (overflow.includes(r.id) ? { ...r, open: false } : r)));
scheduleRemoval(overflow);
return {
id,
dismiss: () => dismiss(id),
update: (patch: ToastOptions) =>
emit(records.map((r) => (r.id === id ? { ...r, ...patch } : r))),
};
}
/** Show a toast from anywhere. Returns its id plus `dismiss` and `update`. */
export const toast = Object.assign(show, { dismiss });
/** Read the live toast list (for custom renderers) plus the imperative API. */
export function useToast() {
const toasts = React.useSyncExternalStore(
subscribe,
() => records,
() => EMPTY,
);
return { toasts, toast, dismiss };
}
/* -------------------------------------------------------------------------- */
/* Toaster */
/* -------------------------------------------------------------------------- */
export type ToasterProps = Omit<
React.ComponentProps<typeof ToastPrimitive.Provider>,
"children" | "label" | "swipeDirection"
> & {
className?: string;
/** Horizontal only: the swipe styles read Radix's x-axis variables. */
swipeDirection?: "right" | "left";
/** Accessible label for the viewport region; `{hotkey}` is replaced. */
label?: string;
/** Pile toasts into a depth stack. Default `true`. */
stacked?: boolean;
/** Keep the stack fanned out. */
expanded?: boolean;
};
/** Mount once near the app root. Renders every toast created with `toast()`. */
export function Toaster({
className,
label,
duration = 5000,
swipeDirection = "right",
stacked,
expanded,
...props
}: ToasterProps) {
const { toasts } = useToast();
return (
<ToastProvider duration={duration} swipeDirection={swipeDirection} {...props}>
{toasts.map(({ id, open, title, description, variant, progress, duration: own, action }) => {
// NaN reads as 0 for the bar, the spinner and the auto-dismiss alike.
const p = progress === undefined ? undefined : Number.isNaN(progress) ? 0 : progress;
const loading = p !== undefined && p < 100;
return (
<Toast
key={id}
open={open}
variant={variant}
duration={loading ? Infinity : own}
onOpenChange={(next) => {
if (!next) dismiss(id);
}}
>
{variant !== undefined || p !== undefined ? (
<ToastIcon variant={variant} loading={loading} />
) : null}
<div className="grid min-w-0 flex-1">
{title ? <ToastTitle>{title}</ToastTitle> : null}
{description ? <ToastDescription>{description}</ToastDescription> : null}
{p !== undefined ? <ToastProgress value={p} /> : null}
</div>
{action ? (
<ToastAction altText={action.altText} onClick={action.onClick}>
{action.label}
</ToastAction>
) : null}
<ToastClose />
</Toast>
);
})}
<ToastViewport className={className} label={label} stacked={stacked} expanded={expanded} />
</ToastProvider>
);
}
