Toast
v0.5.0StableA toast viewport to mount once, anywhere in the app; `showToast()` from anywhere then renders a dismissible, auto-expiring Radix toast in it.
registry/behivetech/atoms/toast
toast-item.tsx
1import * as RadixToast from "@radix-ui/react-toast";2import { getClassName } from "@bht-component-registry/class-names";3import styles from "./toast-item.module.scss";4import type { ToastAction, ToastVariant } from "./toast.js";56export interface ToastItemProps {7 /** Bold headline text */8 title: string;9 /** Optional supporting text shown below the title */10 description?: string;11 /** Accent stripe: adds `--success`, `--warning` or `--error`; "info" adds no modifier */12 variant?: ToastVariant;13 /** Optional button rendered alongside the dismiss control */14 action?: ToastAction;15 /** Called when Radix closes the toast (timeout, swipe, dismiss) */16 onOpenChange: (open: boolean) => void;17}1819/**20 * One toast. A block of its own (`BHT__ToastItem`) so `variant` is styled21 * through a modifier instead of a `data-variant` attribute.22 */23export const ToastItem = ({24 title,25 description,26 variant = "info",27 action,28 onOpenChange,29}: ToastItemProps) => {30 const [rootClass, getChildClass] = getClassName({31 modifiers: {32 success: variant === "success",33 warning: variant === "warning",34 error: variant === "error",35 },36 rootClass: "BHT__ToastItem",37 styles,38 });3940 return (41 <RadixToast.Root className={rootClass} onOpenChange={onOpenChange}>42 <RadixToast.Title className={getChildClass("title")}>43 {title}44 </RadixToast.Title>45 {description && (46 <RadixToast.Description className={getChildClass("description")}>47 {description}48 </RadixToast.Description>49 )}50 {action && (51 <RadixToast.Action52 className={getChildClass("action")}53 altText={action.altText ?? action.label}54 onClick={action.onClick}55 >56 {action.label}57 </RadixToast.Action>58 )}59 <RadixToast.Close className={getChildClass("close")} aria-label="Dismiss">60 ×61 </RadixToast.Close>62 </RadixToast.Root>63 );64};
toast.tsx
1import { useCallback, useEffect, useState } from "react";2import * as RadixToast from "@radix-ui/react-toast";3import { getClassName } from "@bht-component-registry/class-names";4import styles from "./toast.module.scss";5import { ToastItem } from "./toast-item.js";67/** Accent stripe of a toast; "info" is the default and adds no modifier. */8export type ToastVariant = "info" | "success" | "warning" | "error";910export interface ToastAction {11 /** Button label, e.g. "Undo" */12 label: string;13 /** Fired on click; the toast dismisses itself afterwards */14 onClick: () => void;15 /**16 * How to achieve the same thing without the toast, for assistive tech.17 * Required by Radix; defaults to `label`.18 */19 altText?: string;20}2122export interface ToastMessage {23 /** Unique identifier, auto-generated by `showToast` */24 id: string;25 /** Bold headline text */26 title: string;27 /** Optional supporting text shown below the title */28 description?: string;29 /** Controls the accent stripe: "info", "success", "warning" or "error"; defaults to "info" */30 variant?: ToastVariant;31 /** Optional button rendered alongside the dismiss control, e.g. an undo */32 action?: ToastAction;33}3435const TOAST_EVENT = "bht:toast";36const DISMISS_EVENT = "bht:toast-dismiss";3738/**39 * Queues a toast on any mounted `Toast` component. Dispatches a plain `window`40 * event under the hood, so it's callable from anywhere with no React context41 * required — including directly from the browser console, e.g.42 * `showToast({ title: "Saved", variant: "success" })`.43 *44 * @returns the toast's id, which `dismissToast` takes to remove it early45 */46export function showToast(message: Omit<ToastMessage, "id">): string {47 const detail: ToastMessage = { id: crypto.randomUUID(), ...message };48 // `action.onClick` survives this trip intact: `CustomEvent.detail` is handed49 // to same-realm listeners by reference, not structured-cloned, so functions50 // are fine here. This is not a bug in need of a serializable payload.51 window.dispatchEvent(new CustomEvent<ToastMessage>(TOAST_EVENT, { detail }));5253 return detail.id;54}5556/**57 * Removes a toast before it expires, for a message that stopped being true58 * (an error toast after the retry succeeded). Does nothing for an id that has59 * already expired.60 *61 * @param id The id `showToast` returned62 */63export function dismissToast(id: string): void {64 window.dispatchEvent(new CustomEvent<string>(DISMISS_EVENT, { detail: id }));65}6667export interface ToastProps {68 /** How long a toast stays visible before auto-dismissing, in ms. Defaults to 5000. */69 duration?: number;70}7172/**73 * Mount once, anywhere in the app (e.g. a root layout). Listens for `showToast()`74 * calls and renders each as a dismissible, auto-expiring Radix Toast in a fixed75 * viewport.76 */77export const Toast = ({ duration = 5000 }: ToastProps) => {78 const [messages, setMessages] = useState<ToastMessage[]>([]);79 const [, getChildClass] = getClassName({ rootClass: "BHT__Toast", styles });8081 const dismiss = useCallback((id: string) => {82 setMessages((current) => current.filter((message) => message.id !== id));83 }, []);8485 useEffect(() => {86 const handleToastEvent = (event: Event) => {87 const { detail } = event as CustomEvent<ToastMessage>;88 setMessages((current) => [...current, detail]);89 };9091 const handleDismissEvent = (event: Event) => {92 dismiss((event as CustomEvent<string>).detail);93 };9495 window.addEventListener(TOAST_EVENT, handleToastEvent);96 window.addEventListener(DISMISS_EVENT, handleDismissEvent);97 return () => {98 window.removeEventListener(TOAST_EVENT, handleToastEvent);99 window.removeEventListener(DISMISS_EVENT, handleDismissEvent);100 };101 }, [dismiss]);102103 return (104 <RadixToast.Provider duration={duration}>105 {messages.map((message) => (106 <ToastItem107 key={message.id}108 title={message.title}109 description={message.description}110 variant={message.variant}111 action={message.action}112 onOpenChange={(open) => !open && dismiss(message.id)}113 />114 ))}115 <RadixToast.Viewport className={getChildClass("viewport")} />116 </RadixToast.Provider>117 );118};
toast-item.module.scss
1// Material Design 3 snackbar. `inverse-surface` and `inverse-primary` have no2// pre-MD3 names, so their fallbacks are the old surface/on-surface pair3// swapped, which is exactly what "inverse" means.4//5// The `--success` / `--warning` / `--error` accent stripe is kept (it is part of the public6// API) even though MD3 snackbars have no variants.78.BHT__ToastItem {9 display: grid;10 grid-template-columns: 1fr auto;11 align-items: start;12 gap: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem);13 padding: var(--spacing-3, 0.75rem) var(--spacing-4, 1rem);14 background: var(--md-sys-color-inverse-surface, var(--color-on-surface));15 color: var(--md-sys-color-inverse-on-surface, var(--color-surface));16 // Transparent rather than removed so the content box doesn't shift.17 border: 1px solid transparent;18 border-inline-start: 6px solid19 var(--md-sys-color-inverse-primary, var(--color-primary));20 border-radius: var(--md-sys-shape-corner-extra-small, 4px);21 box-shadow: var(--md-sys-elevation-3, var(--shadow-lg));2223 // `*-inverse`, not the bare status alias: the stripe sits on the inverse24 // surface, where the alias (a text colour for a status fill) can be plain25 // black or white. The fallback keeps apps that define no inverse token as26 // they were.27 &--success {28 &.BHT__ToastItem {29 border-inline-start-color: var(30 --color-success-inverse,31 var(--color-success)32 );33 }34 }3536 &--warning {37 &.BHT__ToastItem {38 border-inline-start-color: var(39 --color-warning-inverse,40 var(--color-warning)41 );42 }43 }4445 // `error-inverse`, falling back to `error-container`, not `error`: the stripe46 // sits on the inverse surface, where `error` (tuned for the normal surface)47 // has almost no contrast.48 &--error {49 &.BHT__ToastItem {50 border-inline-start-color: var(51 --color-error-inverse,52 var(--md-sys-color-error-container, var(--color-error))53 );54 }55 }5657 &[data-state="open"] {58 animation: toast-in var(--md-sys-motion-duration-short-3, 150ms)59 var(--md-sys-motion-easing-standard-decelerate, ease-out);60 }6162 &[data-state="closed"] {63 animation: toast-out var(--md-sys-motion-duration-short-3, 150ms)64 var(--md-sys-motion-easing-standard-accelerate, ease-in);65 }6667 &[data-swipe="move"] {68 transform: translateX(var(--radix-toast-swipe-move-x));69 }7071 &[data-swipe="end"] {72 animation: toast-swipe-out var(--md-sys-motion-duration-short-3, 150ms)73 var(--md-sys-motion-easing-standard-accelerate, ease-out);74 }7576 &__title {77 grid-column: 1;78 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));79 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);80 font-weight: var(--md-sys-typescale-label-large-weight, 500);81 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);82 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);83 }8485 &__description {86 grid-column: 1;87 font-family: var(--md-sys-typescale-body-medium-font, var(--font-body));88 font-size: var(--md-sys-typescale-body-medium-size, 0.875rem);89 font-weight: var(--md-sys-typescale-body-medium-weight, 400);90 line-height: var(--md-sys-typescale-body-medium-line-height, 1.25rem);91 letter-spacing: var(--md-sys-typescale-body-medium-tracking, 0.0179em);92 }9394 // Action and close share the interaction treatment: a currentColor state95 // layer on ::after and an invisible 48px touch target on ::before (px, as on96 // Button — a touch target is a physical size, not a typographic one). The97 // target is centered on both axes because the close glyph is narrow as well98 // as short.99 &__action,100 &__close {101 position: relative;102 isolation: isolate;103 background: transparent;104 cursor: pointer;105106 &::before {107 content: "";108 position: absolute;109 top: 50%;110 left: 50%;111 translate: -50% -50%;112 width: 100%;113 height: 100%;114 min-width: 48px;115 min-height: 48px;116 }117118 &::after {119 content: "";120 position: absolute;121 inset: 0;122 border-radius: inherit;123 background: currentColor;124 opacity: 0;125 pointer-events: none;126 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)127 var(--md-sys-motion-easing-standard, ease);128 }129130 &:hover::after {131 opacity: var(--md-sys-state-hover-opacity, 0.08);132 }133134 &:focus-visible::after {135 opacity: var(--md-sys-state-focus-opacity, 0.1);136 }137138 &:active::after {139 opacity: var(--md-sys-state-pressed-opacity, 0.1);140 }141142 &:focus-visible {143 outline: 3px solid144 var(--md-sys-color-inverse-primary, var(--color-primary));145 outline-offset: 2px;146 }147 }148149 // The action sits 12px left of the content edge to align its label, so the150 // default 2px ring offset would land on the 6px stripe. At -1px the ring151 // spans x 8 to 11 (16px root), 2px clear of the stripe, and still outside152 // the label, which starts at x 22.153 &__action:focus-visible {154 outline-offset: -1px;155 }156157 &__action {158 grid-column: 1;159 justify-self: start;160 margin-top: var(--spacing-1, 0.25rem);161 // Pulls the padded state layer left so the label lines up with the title.162 margin-inline-start: calc(-1 * var(--spacing-3, 0.75rem));163 padding: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem);164 // MD3's snackbar action is a text button; the border stays, transparent,165 // only so the button keeps its size.166 border: 1px solid transparent;167 border-radius: var(--md-sys-shape-corner-full, 9999px);168 color: var(--md-sys-color-inverse-primary, var(--color-primary));169 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));170 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);171 font-weight: var(--md-sys-typescale-label-large-weight, 500);172 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);173 line-height: 1;174 }175176 &__close {177 grid-column: 2;178 grid-row: 1 / 3;179 border: none;180 border-radius: var(--md-sys-shape-corner-full, 9999px);181 color: var(--md-sys-color-inverse-on-surface, var(--color-surface));182 // A glyph size, not a type role — the × is sized as an icon.183 font-size: 1.125rem;184 line-height: 1;185 padding: 0;186 }187}188189@keyframes toast-in {190 from {191 opacity: 0;192 transform: translateY(0.5rem);193 }194195 to {196 opacity: 1;197 transform: translateY(0);198 }199}200201@keyframes toast-out {202 from {203 opacity: 1;204 }205206 to {207 opacity: 0;208 }209}210211@keyframes toast-swipe-out {212 from {213 transform: translateX(var(--radix-toast-swipe-end-x));214 }215216 to {217 transform: translateX(100%);218 }219}
toast.module.scss
1// Material Design 3 snackbar viewport; the toast element itself is2// `ToastItem` (toast-item.module.scss).34.BHT__Toast {5 &__viewport {6 position: fixed;7 bottom: var(--spacing-4, 1rem);8 right: var(--spacing-4, 1rem);9 display: flex;10 flex-direction: column;11 gap: var(--spacing-2, 0.5rem);12 width: min(22.5rem, calc(100vw - 2rem));13 z-index: var(--z-toast, 500);14 list-style: none;15 margin: 0;16 padding: 0;17 outline: none;18 }19}
index.ts
1export { Toast, showToast, dismissToast } from "./toast.js";2export type {3 ToastProps,4 ToastMessage,5 ToastVariant,6 ToastAction,7} from "./toast.js";
toast.example.tsx
1import { Toast, showToast } from "./toast.js";23export const BasicToast = () => (4 <>5 <button6 type="button"7 onClick={() =>8 showToast({ title: "Saved", description: "Your changes were saved." })9 }10 >11 Show info toast12 </button>13 <button14 type="button"15 onClick={() => showToast({ title: "Published", variant: "success" })}16 >17 Show success toast18 </button>19 <button20 type="button"21 onClick={() =>22 showToast({ title: "Reservation expires soon", variant: "warning" })23 }24 >25 Show warning toast26 </button>27 <button28 type="button"29 onClick={() => showToast({ title: "Failed to save", variant: "error" })}30 >31 Show error toast32 </button>33 <button34 type="button"35 onClick={() =>36 showToast({37 title: "Structure moved",38 action: {39 label: "Undo",40 onClick: () => showToast({ title: "Move undone" }),41 },42 })43 }44 >45 Show toast with an action46 </button>47 <Toast />48 </>49);
toast.spec.tsx
1import { act, render, screen, waitFor } from "@testing-library/react";2import userEvent from "@testing-library/user-event";3import { Toast, dismissToast, showToast } from "./toast.js";45describe("Toast", () => {6 it("renders an empty viewport before showToast is called", () => {7 render(<Toast />);8 expect(screen.getByRole("region").querySelectorAll("li").length).toBe(0);9 });1011 it("renders a toast's title and description after showToast is called", async () => {12 render(<Toast />);13 act(() => {14 showToast({ title: "Saved", description: "Your changes were saved." });15 });1617 await waitFor(() => {18 expect(screen.getByText("Saved")).toBeTruthy();19 });20 expect(screen.getByText("Your changes were saved.")).toBeTruthy();21 });2223 it("marks the variant through a modifier, not a data attribute", async () => {24 render(<Toast />);25 act(() => {26 showToast({ title: "Saved", variant: "success" });27 });2829 const toast = await screen.findByText("Saved");30 const root = toast.closest("li") as HTMLElement;31 expect(root.className).toContain("--success");32 expect(root).not.toHaveAttribute("data-variant");33 });3435 it("uses the --error modifier for the error variant", async () => {36 render(<Toast />);37 act(() => {38 showToast({ title: "Failed", variant: "error" });39 });4041 const toast = await screen.findByText("Failed");42 expect((toast.closest("li") as HTMLElement).className).toContain("--error");43 });4445 it("uses the --warning modifier for the warning variant", async () => {46 render(<Toast />);47 act(() => {48 showToast({ title: "Expires soon", variant: "warning" });49 });5051 const toast = await screen.findByText("Expires soon");52 expect((toast.closest("li") as HTMLElement).className).toContain(53 "--warning",54 );55 });5657 it("renders no action button when no action is supplied", async () => {58 render(<Toast />);59 act(() => {60 showToast({ title: "Saved" });61 });6263 await waitFor(() => {64 expect(screen.getByText("Saved")).toBeTruthy();65 });66 expect(screen.getAllByRole("button").length).toBe(1);67 expect(screen.getByRole("button", { name: "Dismiss" })).toBeTruthy();68 });6970 it("renders the action button and fires onClick when clicked", async () => {71 const user = userEvent.setup();72 const onClick = vi.fn();73 render(<Toast />);74 act(() => {75 showToast({76 title: "Structure moved",77 action: { label: "Undo", onClick },78 });79 });8081 const action = await screen.findByRole("button", { name: "Undo" });82 await user.click(action);8384 expect(onClick).toHaveBeenCalledTimes(1);85 await waitFor(() => {86 expect(screen.queryByText("Structure moved")).toBeNull();87 });88 });8990 it("falls back to the label for the Radix altText", async () => {91 render(<Toast />);92 act(() => {93 showToast({94 title: "Moved",95 action: { label: "Undo", onClick: vi.fn() },96 });97 });9899 const action = await screen.findByRole("button", { name: "Undo" });100 expect(action).toHaveAttribute("data-radix-toast-announce-alt", "Undo");101 });102103 it("supports multiple simultaneous toasts", async () => {104 render(<Toast />);105 act(() => {106 showToast({ title: "First" });107 showToast({ title: "Second" });108 });109110 await waitFor(() => {111 expect(screen.getByText("First")).toBeTruthy();112 expect(screen.getByText("Second")).toBeTruthy();113 });114 });115116 it("removes a toast early when dismissToast is given the id showToast returned", async () => {117 render(<Toast />);118119 let id = "";120121 act(() => {122 id = showToast({ title: "Order not saved" });123 });124 expect(await screen.findByText("Order not saved")).toBeTruthy();125126 act(() => dismissToast(id));127128 await waitFor(() =>129 expect(screen.queryByText("Order not saved")).toBeNull(),130 );131 });132});