A toast viewport to mount once, anywhere in the app; `showToast()` from anywhere then renders a dismissible, auto-expiring Radix toast in it.

pnpm add @behivetech/atoms.toast

This is the snapshot of v0.5.3, the current version, as recorded on 2026-11-10. The live page runs its examples →

registry/behivetech/atoms/toast at v0.5.3, snapshotted 2026-11-10

toast-item.tsx
1import { useState } from "react";
2import * as RadixToast from "@radix-ui/react-toast";
3import { getClassName } from "@behivetech/get-class-name";
4import styles from "./toast-item.module.scss";
5import type { ToastAction, ToastVariant } from "./toast.js";
6
7export interface ToastItemProps {
8 /** Bold headline text */
9 title: string;
10 /** Optional supporting text shown below the title */
11 description?: string;
12 /** Accent stripe: adds `--success`, `--warning` or `--error`; "info" adds no modifier */
13 variant?: ToastVariant;
14 /** Optional button rendered alongside the dismiss control */
15 action?: ToastAction;
16 /** Called when Radix closes the toast (timeout, swipe, dismiss) */
17 onOpenChange: (open: boolean) => void;
18}
19
20/**
21 * One toast. A block of its own (`BHT__ToastItem`) so `variant` is styled
22 * through a modifier instead of a `data-variant` attribute, and so are the
23 * open, closed and swipe states Radix would otherwise expose as `data-*`.
24 */
25export const ToastItem = ({
26 title,
27 description,
28 variant = "info",
29 action,
30 onOpenChange,
31}: ToastItemProps) => {
32 // Radix reports open state and swipe progress through callbacks, so the
33 // root can carry the matching modifiers instead of `data-state` / `data-swipe`.
34 const [open, setOpen] = useState(true);
35 const [swipe, setSwipe] = useState<"move" | "end" | null>(null);
36
37 const handleOpenChange = (next: boolean) => {
38 setOpen(next);
39 onOpenChange(next);
40 };
41
42 const [rootClass, getChildClass] = getClassName({
43 modifiers: {
44 open,
45 closed: !open,
46 swiping: swipe === "move",
47 swiped: swipe === "end",
48 success: variant === "success",
49 warning: variant === "warning",
50 error: variant === "error",
51 },
52 rootClass: "BHT__ToastItem",
53 styles,
54 });
55
56 return (
57 <RadixToast.Root
58 className={rootClass}
59 open={open}
60 onOpenChange={handleOpenChange}
61 onSwipeStart={() => setSwipe(null)}
62 onSwipeMove={() => setSwipe("move")}
63 onSwipeCancel={() => setSwipe(null)}
64 onSwipeEnd={() => setSwipe("end")}
65 >
66 <RadixToast.Title className={getChildClass("title")}>
67 {title}
68 </RadixToast.Title>
69 {description && (
70 <RadixToast.Description className={getChildClass("description")}>
71 {description}
72 </RadixToast.Description>
73 )}
74 {action && (
75 <RadixToast.Action
76 className={getChildClass("action")}
77 altText={action.altText ?? action.label}
78 onClick={action.onClick}
79 >
80 {action.label}
81 </RadixToast.Action>
82 )}
83 <RadixToast.Close className={getChildClass("close")} aria-label="Dismiss">
84 ×
85 </RadixToast.Close>
86 </RadixToast.Root>
87 );
88};
toast.tsx
1import { useCallback, useEffect, useState } from "react";
2import * as RadixToast from "@radix-ui/react-toast";
3import { getClassName } from "@behivetech/get-class-name";
4import styles from "./toast.module.scss";
5import { ToastItem } from "./toast-item.js";
6
7/** Accent stripe of a toast; "info" is the default and adds no modifier. */
8export type ToastVariant = "info" | "success" | "warning" | "error";
9
10export 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}
21
22export 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}
34
35const TOAST_EVENT = "bht:toast";
36const DISMISS_EVENT = "bht:toast-dismiss";
37
38/**
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 context
41 * 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 early
45 */
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 handed
49 // to same-realm listeners by reference, not structured-cloned, so functions
50 // are fine here. This is not a bug in need of a serializable payload.
51 window.dispatchEvent(new CustomEvent<ToastMessage>(TOAST_EVENT, { detail }));
52
53 return detail.id;
54}
55
56/**
57 * Removes a toast before it expires, for a message that stopped being true
58 * (an error toast after the retry succeeded). Does nothing for an id that has
59 * already expired.
60 *
61 * @param id The id `showToast` returned
62 */
63export function dismissToast(id: string): void {
64 window.dispatchEvent(new CustomEvent<string>(DISMISS_EVENT, { detail: id }));
65}
66
67export interface ToastProps {
68 /** How long a toast stays visible before auto-dismissing, in ms. Defaults to 5000. */
69 duration?: number;
70}
71
72/**
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 fixed
75 * viewport.
76 */
77export const Toast = ({ duration = 5000 }: ToastProps) => {
78 const [messages, setMessages] = useState<ToastMessage[]>([]);
79 const [, getChildClass] = getClassName({ rootClass: "BHT__Toast", styles });
80
81 const dismiss = useCallback((id: string) => {
82 setMessages((current) => current.filter((message) => message.id !== id));
83 }, []);
84
85 useEffect(() => {
86 const handleToastEvent = (event: Event) => {
87 const { detail } = event as CustomEvent<ToastMessage>;
88 setMessages((current) => [...current, detail]);
89 };
90
91 const handleDismissEvent = (event: Event) => {
92 dismiss((event as CustomEvent<string>).detail);
93 };
94
95 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]);
102
103 return (
104 <RadixToast.Provider duration={duration}>
105 {messages.map((message) => (
106 <ToastItem
107 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 no
2// pre-MD3 names, so their fallbacks are the old surface/on-surface pair
3// swapped, which is exactly what "inverse" means.
4//
5// The `--success` / `--warning` / `--error` accent stripe is kept (it is part of the public
6// API) even though MD3 snackbars have no variants.
7
8.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 solid
19 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));
22
23 // `*-inverse`, not the bare status alias: the stripe sits on the inverse
24 // surface, where the alias (a text colour for a status fill) can be plain
25 // black or white. The fallback keeps apps that define no inverse token as
26 // they were.
27 &--success {
28 &.BHT__ToastItem {
29 border-inline-start-color: var(
30 --color-success-inverse,
31 var(--color-success)
32 );
33 }
34 }
35
36 &--warning {
37 &.BHT__ToastItem {
38 border-inline-start-color: var(
39 --color-warning-inverse,
40 var(--color-warning)
41 );
42 }
43 }
44
45 // `error-inverse`, falling back to `error-container`, not `error`: the stripe
46 // 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 }
56
57 &--open {
58 &.BHT__ToastItem {
59 animation: toast-in var(--md-sys-motion-duration-short-3, 150ms)
60 var(--md-sys-motion-easing-standard-decelerate, ease-out);
61 }
62 }
63
64 &--closed {
65 &.BHT__ToastItem {
66 animation: toast-out var(--md-sys-motion-duration-short-3, 150ms)
67 var(--md-sys-motion-easing-standard-accelerate, ease-in);
68 }
69 }
70
71 &--swiping {
72 &.BHT__ToastItem {
73 transform: translateX(var(--radix-toast-swipe-move-x));
74 }
75 }
76
77 &--swiped {
78 &.BHT__ToastItem {
79 animation: toast-swipe-out var(--md-sys-motion-duration-short-3, 150ms)
80 var(--md-sys-motion-easing-standard-accelerate, ease-out);
81 }
82 }
83
84 &__title {
85 grid-column: 1;
86 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));
87 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);
88 font-weight: var(--md-sys-typescale-label-large-weight, 500);
89 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);
90 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);
91 }
92
93 &__description {
94 grid-column: 1;
95 font-family: var(--md-sys-typescale-body-medium-font, var(--font-body));
96 font-size: var(--md-sys-typescale-body-medium-size, 0.875rem);
97 font-weight: var(--md-sys-typescale-body-medium-weight, 400);
98 line-height: var(--md-sys-typescale-body-medium-line-height, 1.25rem);
99 letter-spacing: var(--md-sys-typescale-body-medium-tracking, 0.0179em);
100 }
101
102 // Action and close share the interaction treatment: a currentColor state
103 // layer on ::after and an invisible 48px touch target on ::before (px, as on
104 // Button — a touch target is a physical size, not a typographic one). The
105 // target is centered on both axes because the close glyph is narrow as well
106 // as short.
107 &__action,
108 &__close {
109 position: relative;
110 isolation: isolate;
111 background: transparent;
112 cursor: pointer;
113
114 &::before {
115 content: "";
116 position: absolute;
117 top: 50%;
118 left: 50%;
119 translate: -50% -50%;
120 width: 100%;
121 height: 100%;
122 min-width: 48px;
123 min-height: 48px;
124 }
125
126 &::after {
127 content: "";
128 position: absolute;
129 inset: 0;
130 border-radius: inherit;
131 background: currentColor;
132 opacity: 0;
133 pointer-events: none;
134 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
135 var(--md-sys-motion-easing-standard, ease);
136 }
137
138 &:hover::after {
139 opacity: var(--md-sys-state-hover-opacity, 0.08);
140 }
141
142 &:focus-visible::after {
143 opacity: var(--md-sys-state-focus-opacity, 0.1);
144 }
145
146 &:active::after {
147 opacity: var(--md-sys-state-pressed-opacity, 0.1);
148 }
149
150 &:focus-visible {
151 outline: 3px solid
152 var(--md-sys-color-inverse-primary, var(--color-primary));
153 outline-offset: 2px;
154 }
155 }
156
157 // The action sits 12px left of the content edge to align its label, so the
158 // default 2px ring offset would land on the 6px stripe. At -1px the ring
159 // spans x 8 to 11 (16px root), 2px clear of the stripe, and still outside
160 // the label, which starts at x 22.
161 &__action:focus-visible {
162 outline-offset: -1px;
163 }
164
165 &__action {
166 grid-column: 1;
167 justify-self: start;
168 margin-top: var(--spacing-1, 0.25rem);
169 // Pulls the padded state layer left so the label lines up with the title.
170 margin-inline-start: calc(-1 * var(--spacing-3, 0.75rem));
171 padding: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem);
172 // MD3's snackbar action is a text button; the border stays, transparent,
173 // only so the button keeps its size.
174 border: 1px solid transparent;
175 border-radius: var(--md-sys-shape-corner-full, 9999px);
176 color: var(--md-sys-color-inverse-primary, var(--color-primary));
177 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));
178 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);
179 font-weight: var(--md-sys-typescale-label-large-weight, 500);
180 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);
181 line-height: 1;
182 }
183
184 &__close {
185 grid-column: 2;
186 grid-row: 1 / 3;
187 border: none;
188 border-radius: var(--md-sys-shape-corner-full, 9999px);
189 color: var(--md-sys-color-inverse-on-surface, var(--color-surface));
190 // A glyph size, not a type role — the × is sized as an icon.
191 font-size: 1.125rem;
192 line-height: 1;
193 padding: 0;
194 }
195}
196
197@keyframes toast-in {
198 from {
199 opacity: 0;
200 transform: translateY(0.5rem);
201 }
202
203 to {
204 opacity: 1;
205 transform: translateY(0);
206 }
207}
208
209@keyframes toast-out {
210 from {
211 opacity: 1;
212 }
213
214 to {
215 opacity: 0;
216 }
217}
218
219@keyframes toast-swipe-out {
220 from {
221 transform: translateX(var(--radix-toast-swipe-end-x));
222 }
223
224 to {
225 transform: translateX(100%);
226 }
227}
toast.module.scss
1// Material Design 3 snackbar viewport; the toast element itself is
2// `ToastItem` (toast-item.module.scss).
3
4.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";
2
3export const BasicToast = () => (
4 <>
5 <button
6 type="button"
7 onClick={() =>
8 showToast({ title: "Saved", description: "Your changes were saved." })
9 }
10 >
11 Show info toast
12 </button>
13 <button
14 type="button"
15 onClick={() => showToast({ title: "Published", variant: "success" })}
16 >
17 Show success toast
18 </button>
19 <button
20 type="button"
21 onClick={() =>
22 showToast({ title: "Reservation expires soon", variant: "warning" })
23 }
24 >
25 Show warning toast
26 </button>
27 <button
28 type="button"
29 onClick={() => showToast({ title: "Failed to save", variant: "error" })}
30 >
31 Show error toast
32 </button>
33 <button
34 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 action
46 </button>
47 <Toast />
48 </>
49);