Skip to content

ToastProvider / useToast

LG · 3300B gzip budget

A toast queue with correctly-timed live regions.

Category
Feedback & status
Budget
lg, composes several components
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/toast.json

Copies the source into your project. Pulls in 3 shared registry items: cn, portal, use-hydrated.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import { createContext, useCallback, useContext, useId, useMemo, useState } from "react";
import { cn } from "../lib/cn";
import { Portal } from "./portal";

export type ToastTone = "neutral" | "success" | "danger";

interface ToastItem {
  id: string;
  message: string;
  tone: ToastTone;
}

interface ToastContextValue {
  show: (message: string, tone?: ToastTone) => void;
}

const ToastContext = createContext<ToastContextValue | null>(null);

const TONES: Record<ToastTone, string> = {
  neutral: "bg-neutral-900 text-white dark:bg-neutral-100 dark:text-neutral-900",
  success: "bg-green-700 text-white",
  danger: "bg-red-700 text-white",
};

/**
 * Toast queue and viewport. Each toast is its own live region — mounted
 * empty, then filled a frame later, matching the timing that makes
 * `announce()` actually audible — with `assertive` reserved for `danger`
 * toasts, since a routine success message should not interrupt anything.
 */
export function ToastProvider({ children }: { children: React.ReactNode }) {
  const [toasts, setToasts] = useState<ToastItem[]>([]);
  const baseId = useId();

  const show = useCallback((message: string, tone: ToastTone = "neutral") => {
    const id = `${baseId}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
    setToasts((prev) => [...prev, { id, message, tone }]);
    setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 5000);
  }, [baseId]);

  const value = useMemo(() => ({ show }), [show]);

  return (
    <ToastContext.Provider value={value}>
      {children}
      <Portal>
        <div className="fixed inset-x-0 bottom-4 z-50 flex flex-col items-center gap-2 px-4">
          {toasts.map((toast) => (
            <div
              key={toast.id}
              role={toast.tone === "danger" ? "alert" : "status"}
              className={cn("rounded-lg px-4 py-2.5 text-sm shadow-lg", TONES[toast.tone])}
            >
              {toast.message}
            </div>
          ))}
        </div>
      </Portal>
    </ToastContext.Provider>
  );
}

export function useToast(): ToastContextValue {
  const context = useContext(ToastContext);
  if (!context) throw new Error("useToast must be used within a ToastProvider");
  return context;
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation