A 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 at v0.5.1, snapshotted 2026-11-09
toast-item.tsx
1import { useState } from "react";2import * as RadixToast from "@radix-ui/react-toast";3import { getClassName } from "@bht-component-registry/class-names";4import styles from "./toast-item.module.scss";5import type { ToastAction, ToastVariant } from "./toast.js";67export 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}1920/**21 * One toast. A block of its own (`BHT__ToastItem`) so `variant` is styled22 * through a modifier instead of a `data-variant` attribute, and so are the23 * 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 the33 // 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);3637 const handleOpenChange = (next: boolean) => {38 setOpen(next);39 onOpenChange(next);40 };4142 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 });5556 return (57 <RadixToast.Root58 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.Action76 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 "@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 &--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 }6364 &--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 }7071 &--swiping {72 &.BHT__ToastItem {73 transform: translateX(var(--radix-toast-swipe-move-x));74 }75 }7677 &--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 }8384 &__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 }9293 &__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 }101102 // Action and close share the interaction treatment: a currentColor state103 // layer on ::after and an invisible 48px touch target on ::before (px, as on104 // Button — a touch target is a physical size, not a typographic one). The105 // target is centered on both axes because the close glyph is narrow as well106 // as short.107 &__action,108 &__close {109 position: relative;110 isolation: isolate;111 background: transparent;112 cursor: pointer;113114 &::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 }125126 &::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 }137138 &:hover::after {139 opacity: var(--md-sys-state-hover-opacity, 0.08);140 }141142 &:focus-visible::after {143 opacity: var(--md-sys-state-focus-opacity, 0.1);144 }145146 &:active::after {147 opacity: var(--md-sys-state-pressed-opacity, 0.1);148 }149150 &:focus-visible {151 outline: 3px solid152 var(--md-sys-color-inverse-primary, var(--color-primary));153 outline-offset: 2px;154 }155 }156157 // The action sits 12px left of the content edge to align its label, so the158 // default 2px ring offset would land on the 6px stripe. At -1px the ring159 // spans x 8 to 11 (16px root), 2px clear of the stripe, and still outside160 // the label, which starts at x 22.161 &__action:focus-visible {162 outline-offset: -1px;163 }164165 &__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 }183184 &__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}196197@keyframes toast-in {198 from {199 opacity: 0;200 transform: translateY(0.5rem);201 }202203 to {204 opacity: 1;205 transform: translateY(0);206 }207}208209@keyframes toast-out {210 from {211 opacity: 1;212 }213214 to {215 opacity: 0;216 }217}218219@keyframes toast-swipe-out {220 from {221 transform: translateX(var(--radix-toast-swipe-end-x));222 }223224 to {225 transform: translateX(100%);226 }227}
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);