Skip to content

Toast

Brief notifications that do not interrupt: confirmations, background results and recoverable errors. Toasts are announced once to screen readers, stack up to a limit and pause while the user hovers or focuses them.

Installation

Add Toast to your project with the CK-UI CLI. It copies the source into your project, so the code is yours to change.

Terminal
npx ck-ui-cli@latest add toast
Adds
Toast.tsxButton.tsxThemeScope.tsxto your CK-UI folder (src/components/ck-ui by default)
Installs
@radix-ui/react-slotclass-variance-authorityreact-iconsif your project does not have them yet

First time with CK-UI in this project? Run npx ck-ui-cli@latest init once before adding components. The installation guide explains what it sets up.

Manual installationInstall the packages and copy the files yourself.

Install the following dependencies

Run these commands in your terminal to install the required packages

Terminal
npm install react-icons @radix-ui/react-slot class-variance-authority

Add the Button component

Toast imports ./Button, so add this file next to it

/components/ui/Button.tsx
import * as React from "react";
import Link from "next/link";
import { Slot } from "@radix-ui/react-slot";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";

// Server-compatible: no hooks or context, so Button can be rendered from server components.

export type ButtonVariant = "default" | "stroke" | "lighter" | "ghost";
export type ButtonColor = "primary" | "neutral" | "destructive" | "success";
export type ButtonSize = "xs" | "sm" | "md" | "lg";

/** @deprecated Use `primary` / `destructive`. */
type LegacyButtonColor = "blue" | "danger";
/** @deprecated Use `xs` / `sm` / `md` / `lg`. */
type LegacyButtonSize = "x-small" | "small" | "medium" | "large";

const LEGACY_COLORS: Record<LegacyButtonColor, ButtonColor> = { blue: "primary", danger: "destructive" };
const LEGACY_SIZES: Record<LegacyButtonSize, ButtonSize> = { "x-small": "xs", small: "sm", medium: "md", large: "lg" };

export function resolveButtonColor(color?: ButtonColor | LegacyButtonColor | null): ButtonColor {
    if (!color) return "primary";
    return color in LEGACY_COLORS ? LEGACY_COLORS[color as LegacyButtonColor] : (color as ButtonColor);
}

export function resolveButtonSize(size?: ButtonSize | LegacyButtonSize | null): ButtonSize {
    if (!size) return "md";
    return size in LEGACY_SIZES ? LEGACY_SIZES[size as LegacyButtonSize] : (size as ButtonSize);
}

const buttonRootVariants = cva(
    [
        "group/button relative flex items-center justify-center whitespace-nowrap rounded-lg border size-label-sm",
        "transition-colors duration-base ease-standard focus-ring",
        "disabled:cursor-not-allowed disabled:border-transparent disabled:bg-muted disabled:text-muted-foreground",
        "aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:border-transparent aria-disabled:bg-muted aria-disabled:text-muted-foreground",
    ],
    {
        variants: {
            variant: {
                default: "border-transparent",
                stroke: "bg-transparent hover:border-transparent",
                lighter: "border-transparent",
                ghost: "border-transparent bg-transparent",
            },
            color: {
                primary: "",
                neutral: "",
                destructive: "",
                success: "",
            },
            size: {
                lg: "h-10 min-w-10 gap-3 px-3",
                md: "h-9 min-w-9 gap-2.5 px-2.5",
                sm: "h-8 min-w-8 gap-2 px-2",
                xs: "h-7 min-w-7 gap-2 px-1.5",
            },
        },
        defaultVariants: {
            variant: "default",
            color: "primary",
            size: "md",
        },
        compoundVariants: [
            // Default (filled)
            { variant: "default", color: "primary", class: "bg-primary text-primary-foreground hover:bg-primary-hover" },
            { variant: "default", color: "neutral", class: "bg-secondary text-secondary-foreground hover:bg-secondary-hover" },
            { variant: "default", color: "destructive", class: "bg-destructive text-destructive-foreground hover:bg-destructive-hover" },
            { variant: "default", color: "success", class: "bg-success text-success-foreground hover:bg-success-hover" },

            // Stroke, lighter and ghost colour their text with the -strong shade (AA on background and -subtle).
            { variant: "stroke", color: "primary", class: "border-primary-border text-primary-strong hover:bg-primary-subtle" },
            { variant: "stroke", color: "neutral", class: "border-border text-muted-foreground hover:bg-muted hover:text-foreground" },
            { variant: "stroke", color: "destructive", class: "border-destructive-border text-destructive-strong hover:bg-destructive-subtle" },
            { variant: "stroke", color: "success", class: "border-success-border text-success-strong hover:bg-success-subtle" },

            // Lighter
            { variant: "lighter", color: "primary", class: "bg-primary-subtle text-primary-strong hover:border-primary-border hover:bg-transparent" },
            { variant: "lighter", color: "neutral", class: "bg-muted text-muted-foreground hover:border-border hover:bg-transparent hover:text-foreground" },
            { variant: "lighter", color: "destructive", class: "bg-destructive-subtle text-destructive-strong hover:border-destructive-border hover:bg-transparent" },
            { variant: "lighter", color: "success", class: "bg-success-subtle text-success-strong hover:border-success-border hover:bg-transparent" },

            // Ghost
            { variant: "ghost", color: "primary", class: "text-primary-strong hover:bg-primary-subtle" },
            { variant: "ghost", color: "neutral", class: "text-muted-foreground hover:bg-muted hover:text-foreground" },
            { variant: "ghost", color: "destructive", class: "text-destructive-strong hover:bg-destructive-subtle" },
            { variant: "ghost", color: "success", class: "text-success-strong hover:bg-success-subtle" },
        ],
    }
);

// Without an explicit size, Button.Icon follows the parent Button's data-size (pure CSS, works with asChild).
const buttonIconVariants = cva("flex shrink-0 items-center justify-center", {
    variants: {
        size: {
            inherit: "size-5 group-data-[size=sm]/button:size-4 group-data-[size=xs]/button:size-4",
            lg: "size-5",
            md: "size-5",
            sm: "size-4",
            xs: "size-4",
        },
    },
    defaultVariants: {
        size: "inherit",
    },
});

export interface ButtonRootProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color"> {
    /** Render the single child element instead of a <button>, merging Button's props onto it. */
    asChild?: boolean;
    variant?: ButtonVariant;
    color?: ButtonColor | LegacyButtonColor;
    size?: ButtonSize | LegacyButtonSize;
    /** Disables the button and marks it busy while an action runs. */
    loading?: boolean;
    /** Render a Next.js link styled as a button. */
    href?: string;
}

function LoadingIndicator() {
    return (
        <svg className="size-4 shrink-0 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
            <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
        </svg>
    );
}

const ButtonRoot = React.forwardRef<HTMLButtonElement, ButtonRootProps>(
    ({ asChild = false, className, variant, color, size, loading = false, href, disabled, type, children, ...props }, ref) => {
        const resolvedSize = resolveButtonSize(size);
        const classes = cn(buttonRootVariants({ variant, color: resolveButtonColor(color), size: resolvedSize }), className);
        const isDisabled = Boolean(disabled || loading);

        if (asChild) {
            return (
                <Slot ref={ref} className={classes} data-size={resolvedSize} aria-disabled={isDisabled || undefined} aria-busy={loading || undefined} {...props}>
                    {children}
                </Slot>
            );
        }

        if (href !== undefined) {
            const anchorProps = props as React.AnchorHTMLAttributes<HTMLAnchorElement>;
            const anchorRef = ref as unknown as React.Ref<HTMLAnchorElement>;

            // A disabled link has no href, so it cannot be followed or focused.
            if (isDisabled) {
                return (
                    <a ref={anchorRef} className={classes} data-size={resolvedSize} aria-disabled="true" aria-busy={loading || undefined} {...anchorProps}>
                        {loading && <LoadingIndicator />}
                        {children}
                    </a>
                );
            }

            return (
                <Link ref={anchorRef} href={href} className={classes} data-size={resolvedSize} {...anchorProps}>
                    {children}
                </Link>
            );
        }

        return (
            <button ref={ref} type={type ?? "button"} className={classes} data-size={resolvedSize} disabled={isDisabled} aria-busy={loading || undefined} {...props}>
                {loading && <LoadingIndicator />}
                {children}
            </button>
        );
    }
);
ButtonRoot.displayName = "Button.Root";

export interface ButtonIconProps extends React.HTMLAttributes<HTMLElement> {
    size?: ButtonSize | LegacyButtonSize;
    /** Element or icon component to render, e.g. `as={RiAddLine}`. */
    as?: React.ElementType;
}

const ButtonIcon = React.forwardRef<HTMLElement, ButtonIconProps>(({ as: Component = "span", className, size, ...props }, ref) => {
    return <Component ref={ref} aria-hidden="true" className={cn(buttonIconVariants({ size: size ? resolveButtonSize(size) : "inherit" }), className)} {...props} />;
});
ButtonIcon.displayName = "Button.Icon";

const Button = {
    Root: ButtonRoot,
    Icon: ButtonIcon,
};

export { Button, ButtonRoot as Root, ButtonIcon as Icon, buttonRootVariants, buttonIconVariants };
export default Button;

Add the ThemeScope component

Toast imports ./ThemeScope, so add this file next to it

/components/ui/ThemeScope.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/*
 * A local light/dark boundary. Everything inside renders in `theme`, whatever the site theme is: the semantic tokens
 * (`bg-background`, `text-foreground`, …) and every `dark:` utility resolve to the nearest boundary, not to <html>.
 * Overlays that portal out of the tree (Select, Popover, Tooltip, Dropdown, Modal, AlertDialog, Drawer, Toast) render
 * into a layer that carries the same theme, so a menu opened inside a dark scope is dark too.
 *
 * It never touches <html>, the site's theme state or its storage. Without `theme` it renders no theme class and
 * follows the surrounding theme. Nest at most one scope below the site theme (see `darkMode` in tailwind.config.ts).
 */

export type ScopeTheme = "light" | "dark";

const PortalContainerContext = React.createContext<HTMLElement | null>(null);

/** Where overlays portal to: the nearest themed ThemeScope's layer, or null for the default (document.body). */
export function usePortalContainer() {
    return React.useContext(PortalContainerContext);
}

// A themed layer at the end of <body> for portalled overlays. Only created while the scope sets a theme, so a scope
// that follows the site leaves the document exactly as it was.
function usePortalLayer(theme: ScopeTheme | undefined) {
    const [layer, setLayer] = React.useState<HTMLElement | null>(null);
    const themed = theme !== undefined;

    React.useEffect(() => {
        if (!themed) return;
        const element = document.createElement("div");
        element.setAttribute("data-theme-scope-layer", "");
        document.body.appendChild(element);
        setLayer(element);
        return () => {
            element.remove();
            setLayer(null);
        };
    }, [themed]);

    React.useEffect(() => {
        if (!layer || !theme) return;
        // text-foreground: portalled text that sets no colour of its own resolves against the layer's tokens.
        layer.className = `${theme} text-foreground`;
    }, [layer, theme]);

    return layer;
}

export interface RootProps extends React.HTMLAttributes<HTMLDivElement> {
    /** Theme for this subtree. Leave undefined to follow the surrounding (site) theme. */
    theme?: ScopeTheme;
}

const Root = React.forwardRef<HTMLDivElement, RootProps>(({ theme, className, children, ...props }, ref) => {
    const layer = usePortalLayer(theme);
    // A scope that follows its surroundings keeps the surrounding scope's layer.
    const outerLayer = usePortalContainer();

    return (
        <div ref={ref} data-theme-scope={theme} className={cn(theme && [theme, "text-foreground"], className)} {...props}>
            <PortalContainerContext.Provider value={theme ? layer : outerLayer}>{children}</PortalContainerContext.Provider>
        </div>
    );
});
Root.displayName = "ThemeScope.Root";

export { Root };

Create or update your Toast component

Copy this code to create your Toast component

/components/ui/Toast.tsx
"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { RiAlertLine, RiCheckboxCircleLine, RiCloseLine, RiErrorWarningLine, RiInformationLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
import * as Button from "./Button";
import { usePortalContainer } from "./ThemeScope";

/*
 * Toasts: brief, non-blocking notifications.
 *   toast({ title, description, color, duration, action })  — call from anywhere (event handlers, effects)
 *   <Toaster />                                               — mount once, e.g. in the root layout
 * Accessibility:
 *   - Announced once through hidden live regions owned by the Toaster ("polite" by default, "assertive" for
 *     destructive). The visible toasts are not live regions, so nothing is read twice.
 *   - The toasts sit in a labelled region. F8 (configurable) moves focus there; Tab reaches the action and
 *     close buttons; Escape dismisses the focused toast.
 *   - Timers pause while the pointer is over the toasts, while focus is inside them and while the window
 *     is in the background. `duration: Infinity` keeps a toast until it is dismissed.
 * Swipe-to-dismiss is not implemented.
 */

export type ToastColor = "neutral" | "info" | "success" | "warning" | "destructive";
export type ToastPoliteness = "polite" | "assertive";

export interface ToastActionOptions {
    label: React.ReactNode;
    /** Called on click. The toast then closes unless you call `event.preventDefault()`. */
    onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
}

export interface ToastOptions {
    /** Pass an existing id to update that toast in place. */
    id?: string;
    title?: React.ReactNode;
    description?: React.ReactNode;
    color?: ToastColor;
    /** Milliseconds before it closes. `Infinity` keeps it open. Defaults to the Toaster's `duration`. */
    duration?: number;
    action?: ToastActionOptions;
    /** Show the close button (default true). */
    dismissible?: boolean;
    /** Screen-reader urgency. Defaults to "assertive" for destructive toasts and "polite" for the rest. */
    politeness?: ToastPoliteness;
    /** Called when the toast starts closing (timeout, close button, Escape, action or `dismiss`). */
    onDismiss?: (id: string) => void;
}

export interface ToastRecord extends Omit<ToastOptions, "id"> {
    id: string;
    open: boolean;
    /** Increments on every update; restarts the timer and the announcement. */
    version: number;
}

// ---------------------------------------------------------------------------------------------
// Store (module level, shared by toast() and every Toaster)
// ---------------------------------------------------------------------------------------------

const EXIT_DURATION = 200;
const EMPTY: ToastRecord[] = [];

let records: ToastRecord[] = [];
let idCounter = 0;
const listeners = new Set<() => void>();
const removeTimers = new Map<string, ReturnType<typeof setTimeout>>();
// Toasts that have been on screen (only those animate out) and the version last announced.
const shownIds = new Set<string>();
const announcedVersions = new Map<string, number>();

function setRecords(next: ToastRecord[]) {
    records = next;
    listeners.forEach((listener) => listener());
}

function subscribe(listener: () => void) {
    listeners.add(listener);
    return () => {
        listeners.delete(listener);
    };
}

const getSnapshot = () => records;
const getServerSnapshot = () => EMPTY;

// One Toaster renders at a time. Extra instances (e.g. one in a layout and one in a page) render nothing,
// so toasts are never shown or announced twice; the next instance takes over if the active one unmounts.
const toasterOwners: symbol[] = [];
const ownerListeners = new Set<() => void>();

function subscribeOwners(listener: () => void) {
    ownerListeners.add(listener);
    return () => {
        ownerListeners.delete(listener);
    };
}

const getOwner = () => toasterOwners[0] ?? null;
const getServerOwner = () => null;

function claimToaster(id: symbol) {
    toasterOwners.push(id);
    if (toasterOwners.length > 1 && process.env.NODE_ENV !== "production") {
        console.warn("CK-UI: more than one <Toaster /> is mounted. Only the first one renders toasts; mount a single Toaster near the root.");
    }
    ownerListeners.forEach((listener) => listener());
    return () => {
        const index = toasterOwners.indexOf(id);
        if (index > -1) toasterOwners.splice(index, 1);
        ownerListeners.forEach((listener) => listener());
    };
}

function clearRemoveTimer(id: string) {
    const timer = removeTimers.get(id);
    if (timer) clearTimeout(timer);
    removeTimers.delete(id);
}

function upsert(options: ToastOptions): string {
    const id = options.id ?? `toast-${++idCounter}`;
    const existing = records.find((record) => record.id === id);

    if (existing) {
        clearRemoveTimer(id);
        setRecords(records.map((record) => (record.id === id ? { ...record, ...options, id, open: true, version: record.version + 1 } : record)));
    } else {
        setRecords([...records, { ...options, id, open: true, version: 0 }]);
    }

    return id;
}

/** Closes one toast, or every toast when called without an id. */
function dismiss(id?: string) {
    const closing = records.filter((record) => record.open && (id === undefined || record.id === id));
    if (closing.length === 0) return;

    const closingIds = new Set(closing.map((record) => record.id));
    setRecords(records.map((record) => (closingIds.has(record.id) ? { ...record, open: false } : record)));

    closing.forEach((record) => {
        record.onDismiss?.(record.id);
        clearRemoveTimer(record.id);
        removeTimers.set(
            record.id,
            setTimeout(() => {
                removeTimers.delete(record.id);
                shownIds.delete(record.id);
                announcedVersions.delete(record.id);
                setRecords(records.filter((entry) => entry.id !== record.id));
            }, EXIT_DURATION)
        );
    });
}

export interface ToastHandle {
    id: string;
    dismiss: () => void;
    update: (options: Omit<ToastOptions, "id">) => void;
}

function createToast(options: ToastOptions): ToastHandle {
    const id = upsert(options);
    return {
        id,
        dismiss: () => dismiss(id),
        update: (next) => {
            upsert({ ...next, id });
        },
    };
}

type ToastShortcut = (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "color">) => ToastHandle;
const shortcut =
    (color: ToastColor): ToastShortcut =>
    (title, options) =>
        createToast({ ...options, title, color });

/** Shows a toast. Also: toast.success / toast.error / toast.warning / toast.info / toast.dismiss. */
export const toast = Object.assign(createToast, {
    success: shortcut("success"),
    error: shortcut("destructive"),
    warning: shortcut("warning"),
    info: shortcut("info"),
    dismiss,
});

/** The current toasts plus `toast` and `dismiss`. Re-renders when toasts change. */
export function useToast() {
    const toasts = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
    return { toasts, toast, dismiss };
}

// ---------------------------------------------------------------------------------------------
// Toaster
// ---------------------------------------------------------------------------------------------

export type ToastPosition = "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right";

const positionClasses: Record<ToastPosition, string> = {
    "top-left": "top-0 left-0",
    "top-center": "top-0 left-0 sm:left-1/2 sm:-translate-x-1/2",
    "top-right": "top-0 right-0",
    "bottom-left": "bottom-0 left-0",
    "bottom-center": "bottom-0 left-0 sm:left-1/2 sm:-translate-x-1/2",
    "bottom-right": "bottom-0 right-0",
};

const colorIcons: Record<Exclude<ToastColor, "neutral">, React.ElementType> = {
    info: RiInformationLine,
    success: RiCheckboxCircleLine,
    warning: RiAlertLine,
    destructive: RiErrorWarningLine,
};

const iconColorClasses: Record<Exclude<ToastColor, "neutral">, string> = {
    info: "text-info-strong",
    success: "text-success-strong",
    warning: "text-warning-strong",
    destructive: "text-destructive-strong",
};

const DEFAULT_HOTKEY = ["F8"];

export interface ToasterProps {
    position?: ToastPosition;
    /** Maximum toasts on screen. Newer toasts push older ones into a queue until there is room. */
    limit?: number;
    /** Default time on screen in milliseconds. */
    duration?: number;
    /** Accessible name of the notifications region. */
    label?: string;
    /** Keys that move focus to the toasts, matched against `event.code` or modifier flags (e.g. ["altKey", "KeyT"]). */
    hotkey?: string[];
    /** Accessible name of each close button. */
    closeLabel?: string;
    className?: string;
}

type Announcement = { key: string; politeness: ToastPoliteness; content: React.ReactNode };

/** Renders the toasts. Mount it once; `toast()` works anywhere after that. */
export function Toaster({
    position = "bottom-right",
    limit = 3,
    duration = 5000,
    label = "Notifications",
    hotkey = DEFAULT_HOTKEY,
    closeLabel = "Close notification",
    className,
}: ToasterProps) {
    const all = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
    const [instanceId] = React.useState(() => Symbol("Toaster"));
    const owner = React.useSyncExternalStore(subscribeOwners, getOwner, getServerOwner);
    const [mounted, setMounted] = React.useState(false);
    const [hovered, setHovered] = React.useState(false);
    const [focusWithin, setFocusWithin] = React.useState(false);
    const [windowBlurred, setWindowBlurred] = React.useState(false);
    const [announcements, setAnnouncements] = React.useState<Announcement[]>([]);
    const viewportRef = React.useRef<HTMLElement>(null);
    const returnFocusRef = React.useRef<HTMLElement | null>(null);
    const announceTimers = React.useRef(new Set<ReturnType<typeof setTimeout>>());
    const container = usePortalContainer();

    React.useEffect(() => setMounted(true), []);
    React.useEffect(() => claimToaster(instanceId), [instanceId]);

    React.useEffect(() => {
        const timers = announceTimers.current;
        return () => timers.forEach(clearTimeout);
    }, []);

    // Pause while the window is in the background.
    React.useEffect(() => {
        const onBlur = () => setWindowBlurred(true);
        const onFocus = () => setWindowBlurred(false);
        window.addEventListener("blur", onBlur);
        window.addEventListener("focus", onFocus);
        return () => {
            window.removeEventListener("blur", onBlur);
            window.removeEventListener("focus", onFocus);
        };
    }, []);

    // Hotkey moves focus into the notifications region.
    const hotkeyKey = hotkey.join("+");
    React.useEffect(() => {
        const keys = hotkeyKey.split("+");
        const onKeyDown = (event: KeyboardEvent) => {
            const matches = keys.every((key) => (event as unknown as Record<string, unknown>)[key] === true || event.code === key);
            if (!matches || !viewportRef.current) return;
            returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
            viewportRef.current.focus();
        };
        document.addEventListener("keydown", onKeyDown);
        return () => document.removeEventListener("keydown", onKeyDown);
    }, [hotkeyKey]);

    const announce = React.useCallback((record: ToastRecord) => {
        const politeness = record.politeness ?? (record.color === "destructive" ? "assertive" : "polite");
        const key = `${record.id}:${record.version}`;
        const content = (
            <>
                {record.title && <span>{record.title}</span>}
                {record.title && record.description ? <span>. </span> : null}
                {record.description && <span>{record.description}</span>}
            </>
        );
        setAnnouncements((current) => [...current, { key, politeness, content }]);
        // Removing the text afterwards is not announced; it only keeps the hidden region from duplicating
        // the visible toast for screen-reader users who browse the page.
        const timer = setTimeout(() => {
            announceTimers.current.delete(timer);
            setAnnouncements((current) => current.filter((entry) => entry.key !== key));
        }, 1000);
        announceTimers.current.add(timer);
    }, []);

    // When the focused toast closes, keep focus inside the region (next toast) or send it back.
    const moveFocusFrom = React.useCallback((item: HTMLElement) => {
        if (!item.contains(document.activeElement)) return;
        const items = Array.from(viewportRef.current?.querySelectorAll<HTMLElement>("[data-toast][data-state=open]") ?? []);
        const index = items.indexOf(item);
        const next = items[index + 1] ?? items[index - 1];

        if (next) next.focus();
        else if (returnFocusRef.current?.isConnected) returnFocusRef.current.focus();
        else viewportRef.current?.focus();
    }, []);

    const openRecords = all.filter((record) => record.open);
    const visibleOpenIds = new Set(openRecords.slice(-Math.max(1, limit)).map((record) => record.id));
    const rendered = all.filter((record) => visibleOpenIds.has(record.id) || (!record.open && shownIds.has(record.id)));
    const isTop = position.startsWith("top");
    // Newest toast sits closest to the screen edge.
    const ordered = isTop ? [...rendered].reverse() : rendered;
    const paused = hovered || focusWithin || windowBlurred;
    const hotkeyLabel = hotkey.map((key) => key.replace(/^(Key|Digit)/, "").replace(/Key$/, "")).join("+");

    const region = (
        <>
            <section
                ref={viewportRef}
                aria-label={`${label} (${hotkeyLabel})`}
                tabIndex={-1}
                onPointerEnter={() => setHovered(true)}
                onPointerLeave={() => setHovered(false)}
                onFocus={() => setFocusWithin(true)}
                onBlur={(event) => {
                    if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocusWithin(false);
                }}
                className={cn("pointer-events-none fixed z-toast w-full p-4 outline-none sm:max-w-[420px]", positionClasses[position], className)}
            >
                <ol className="flex flex-col gap-2">
                    {ordered.map((record) => (
                        <ToastItem
                            key={record.id}
                            record={record}
                            defaultDuration={duration}
                            paused={paused}
                            fromTop={isTop}
                            closeLabel={closeLabel}
                            announce={announce}
                            moveFocusFrom={moveFocusFrom}
                        />
                    ))}
                </ol>
            </section>
            <div className="sr-only">
                <div aria-live="polite" aria-atomic="false" aria-relevant="additions" data-toast-announcer="polite">
                    {announcements
                        .filter((entry) => entry.politeness === "polite")
                        .map((entry) => (
                            <p key={entry.key}>{entry.content}</p>
                        ))}
                </div>
                <div aria-live="assertive" aria-atomic="false" aria-relevant="additions" data-toast-announcer="assertive">
                    {announcements
                        .filter((entry) => entry.politeness === "assertive")
                        .map((entry) => (
                            <p key={entry.key}>{entry.content}</p>
                        ))}
                </div>
            </div>
        </>
    );

    return mounted && owner === instanceId ? createPortal(region, container ?? document.body) : null;
}
Toaster.displayName = "Toast.Toaster";

interface ToastItemProps {
    record: ToastRecord;
    defaultDuration: number;
    paused: boolean;
    fromTop: boolean;
    closeLabel: string;
    announce: (record: ToastRecord) => void;
    moveFocusFrom: (item: HTMLElement) => void;
}

function ToastItem({ record, defaultDuration, paused, fromTop, closeLabel, announce, moveFocusFrom }: ToastItemProps) {
    const { id, open, version, title, description, color = "neutral", action, dismissible = true } = record;
    const duration = record.duration ?? defaultDuration;
    const itemRef = React.useRef<HTMLLIElement>(null);
    const remainingRef = React.useRef(duration);
    const Icon = color === "neutral" ? null : colorIcons[color];

    React.useEffect(() => {
        if (open) shownIds.add(id);
    }, [id, open]);

    // Announce once per version (StrictMode-safe: the map is checked before announcing).
    React.useEffect(() => {
        if (!open || announcedVersions.get(id) === version) return;
        announcedVersions.set(id, version);
        announce(record);
    }, [id, open, version, record, announce]);

    // A new version (update) restarts the timer.
    React.useEffect(() => {
        remainingRef.current = duration;
    }, [version, duration]);

    React.useEffect(() => {
        if (!open || paused || !Number.isFinite(duration)) return;
        const startedAt = Date.now();
        const timer = setTimeout(() => dismiss(id), Math.max(0, remainingRef.current));
        return () => {
            clearTimeout(timer);
            remainingRef.current -= Date.now() - startedAt;
        };
    }, [id, open, paused, duration, version]);

    const close = () => {
        if (itemRef.current) moveFocusFrom(itemRef.current);
        dismiss(id);
    };

    return (
        <li
            ref={itemRef}
            data-toast=""
            data-state={open ? "open" : "closed"}
            data-color={color}
            tabIndex={open ? 0 : -1}
            aria-hidden={open ? undefined : true}
            onKeyDown={(event) => {
                if (event.key !== "Escape" || !open) return;
                event.stopPropagation();
                close();
            }}
            className={cn(
                "pointer-events-auto relative flex w-full items-start gap-3 rounded-2xl border bg-surface p-4 text-surface-foreground shadow-medium focus-ring",
                color === "destructive" ? "border-destructive-border" : "border-border",
                "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:pointer-events-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
                fromTop ? "data-[state=open]:slide-in-from-top-2" : "data-[state=open]:slide-in-from-bottom-2"
            )}
        >
            {Icon && <Icon aria-hidden="true" className={cn("mt-px size-5 shrink-0", iconColorClasses[color as Exclude<ToastColor, "neutral">])} />}
            <div className={cn("flex min-w-0 flex-1 flex-col gap-0.5", dismissible && "pr-6")}>
                {title && <div className="size-label-sm text-foreground">{title}</div>}
                {description && <div className="size-p-sm text-muted-foreground">{description}</div>}
                {action && (
                    <div className="mt-2.5">
                        <Button.Root
                            size="xs"
                            variant="stroke"
                            color="neutral"
                            className="text-foreground"
                            tabIndex={open ? undefined : -1}
                            onClick={(event) => {
                                action.onClick(event);
                                if (!event.defaultPrevented) close();
                            }}
                        >
                            {action.label}
                        </Button.Root>
                    </div>
                )}
            </div>
            {dismissible && (
                <button
                    type="button"
                    aria-label={closeLabel}
                    tabIndex={open ? undefined : -1}
                    onClick={close}
                    className="absolute right-2 top-2 flex size-7 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-fast ease-standard hover:bg-muted hover:text-foreground focus-ring"
                >
                    <RiCloseLine aria-hidden="true" className="size-4" />
                </button>
            )}
        </li>
    );
}

Test Your Component

  • 1. Add the component to a test page
  • 2. Check all props and variants work as expected
  • 3. Verify responsive behavior
  • 4. Test with different content/data
  • 5. Ensure accessibility features work correctly

Setup

Mount one Toaster near the root of your app, then call toast() from event handlers or effects in any client component. The shadcn-style useToast() hook is also available, from the same file or from @/hooks/use-toast.

app/layout.tsx
// app/layout.tsx — mount the Toaster once
import { Toaster } from "@/components/ui/Toast";

export default function RootLayout({ children }: { children: React.ReactNode }) {
    return (
        <html lang="en">
            <body>
                {children}
                <Toaster position="bottom-right" limit={3} duration={5000} />
            </body>
        </html>
    );
}

// Any client component
import { toast } from "@/components/ui/Toast";

toast({ title: "Saved", description: "Your changes are live." });
toast.success("Invitation sent");
toast.error("Upload failed", { description: "The file is too large." });

Colors

Set color, or use the shortcuts toast.success, toast.error, toast.warning and toast.info.

With Action

An action button closes the toast after running, unless its handler calls event.preventDefault(). Give toasts with actions a longer duration.

Archived: 0

Duration

duration is in milliseconds (default 5000, configurable on the Toaster). Infinity keeps a toast until it is dismissed.

Updating a toast

toast() returns a handle. update() changes the same toast in place, which is announced again and restarts its timer.

Stacking

The Toaster shows at most limit toasts (default 3), newest nearest the screen edge. Older ones wait in a queue and appear when there is room.

Accessibility

  • Each toast is announced once through hidden live regions owned by the Toaster: politely by default, assertively for destructive toasts (override with politeness). The visible toasts are not live regions, so nothing is read twice, and not every toast is an alert.
  • Toasts sit in a region labelled "Notifications (F8)". Press F8 to move focus there, Tab to reach a toast's action and close buttons, and Escape to dismiss the focused toast. Focus then moves to the next toast or back to where it was.
  • Timers pause while the pointer is over the toasts, while focus is inside them and while the window is in the background.
  • Do not put the only way to do something in a toast; it disappears. Offer the same action elsewhere (undo from the Archive page), or use duration: Infinity.
  • Use an Alert Dialog, not a toast, for anything that needs a decision.
  • Animations are removed when the user prefers reduced motion. Swipe-to-dismiss is not supported; use the close button or Escape.

API Reference

Built with React only (no extra dependency). Import with import { Toaster, toast, useToast } from "@/components/ui/Toast".

Toaster

Renders the toasts in a portal. Mount it once.

PropTypeDefaultDescription
position
"top-left""top-center""top-right""bottom-left""bottom-center""bottom-right"
"bottom-right"

Corner of the screen. Full width with a 16px gutter on small screens.

limit"number""3"

Maximum toasts on screen; the rest are queued.

duration"number""5000"

Default time on screen in milliseconds.

label"string""Notifications"

Accessible name of the region.

hotkey"string[]""["F8"]"

Keys that focus the region, as event.code values or modifier flags, e.g. ["altKey", "KeyT"].

closeLabel"string""Close notification"

Accessible name of each close button.

className"string""undefined"

Classes for the region.

toast(options)

Shows a toast and returns { id, dismiss(), update(options) }. Shortcuts: toast.success / error / warning / info(title, options). toast.dismiss(id?) closes one or all.

PropTypeDefaultDescription
title"ReactNode""undefined"

Main text.

description"ReactNode""undefined"

Secondary text.

color
"neutral""info""success""warning""destructive"
"neutral"

Intent and icon.

duration"number""Toaster duration"

Milliseconds before closing. Infinity keeps it open.

action"{ label, onClick }""undefined"

One action button. Closes the toast unless onClick calls event.preventDefault().

dismissible"boolean""true"

Show the close button.

politeness
"polite""assertive"
"polite (assertive for destructive)"

How screen readers announce it.

id"string""generated"

Pass an existing id to update that toast.

onDismiss"(id: string) => void""undefined"

Called when the toast starts closing.

useToast()

Returns { toasts, toast, dismiss } and re-renders when toasts change.

PropTypeDefaultDescription
toasts"ToastRecord[]""[]"

Every toast, including closing and queued ones (open tells them apart).

toast"function""-"

Same as the toast export.

dismiss"(id?: string) => void""-"

Close one toast or all of them.