component — feedback
Toast
Transient, non-blocking feedback messages, styled with the quebi design system. Self-contained queue with a portalled, aria-live stack and five status intents.
- toast
- notification
- feedback
- alert
- snackbar
04 examples
Autosave
Default toast for autosave confirmations. Keep copy specific — include the timestamp.
Status intents
Success / warning / error / info map to the quebi status ramps.
With description
A bold title with a muted secondary line below.
Persistent
Pass duration={0} to keep a toast until it is dismissed.
source
toast.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { createPortal } from "react-dom"
import { cn, tv } from "@/lib/utils"
import { Button } from "react-aria-components"
/**
* Toast — quebi design system
*
* Transient, non-blocking feedback. Self-contained (no external toast lib):
* a context-backed queue renders a fixed, portalled stack with an aria-live
* region for accessibility.
*
* Every toast is the same overlay — `bg-quebi-overlay text-quebi-on-overlay`
* with the overlay shadow (slate in Light, ink with a 1px ring in Dark) and
* the control radius. The intent is carried by the icon's shape and a 2px
* rule down the leading edge: `success`, `warning` and `danger` in their state
* tokens, `info` in the overlay's own text colour, `default` with neither. The
* icon stays `on-overlay`: the state tokens are text colours for light grounds
* and do not clear contrast on slate. The surface itself is never tinted. Auto-dismiss after `duration` ms (set to 0 to keep until
* dismissed).
*
* Usage:
* <ToastProvider> wraps your app, then call `const toast = useToast()`
* and fire `toast.success("Saved")` / `toast({ title, description })`.
*/
type IconProps = React.SVGProps<SVGSVGElement>
// `aria-hidden` is not in here: these icons are decorative — the status ones
// restate the toast's intent, and CloseIcon sits inside a button that is
// already labelled "Dismiss notification" — and lint/a11y/noSvgWithoutTitle
// cannot see an attribute arriving through a spread. So each <svg> states it
// literally, before the spreads, where a caller that needs a labelled icon can
// still override it.
const baseIconProps: IconProps = {
viewBox: "0 0 24 24",
fill: "currentColor",
}
const CheckCircleIcon = (props: IconProps) => (
<svg aria-hidden="true" {...baseIconProps} {...props}>
<path
fillRule="evenodd"
d="M2.25 12a9.75 9.75 0 1 1 19.5 0 9.75 9.75 0 0 1-19.5 0Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z"
clipRule="evenodd"
/>
</svg>
)
const ExclamationTriangleIcon = (props: IconProps) => (
<svg aria-hidden="true" {...baseIconProps} {...props}>
<path
fillRule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
clipRule="evenodd"
/>
</svg>
)
const XCircleIcon = (props: IconProps) => (
<svg aria-hidden="true" {...baseIconProps} {...props}>
<path
fillRule="evenodd"
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-1.72 6.97a.75.75 0 1 0-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06L12 13.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L13.06 12l1.72-1.72a.75.75 0 1 0-1.06-1.06L12 10.94l-1.72-1.72Z"
clipRule="evenodd"
/>
</svg>
)
const InformationCircleIcon = (props: IconProps) => (
<svg aria-hidden="true" {...baseIconProps} {...props}>
<path
fillRule="evenodd"
d="M2.25 12a9.75 9.75 0 1 1 19.5 0 9.75 9.75 0 0 1-19.5 0Zm9-1.5a.75.75 0 0 0 0 1.5h.255a.75.75 0 0 1 .73.926l-.708 2.836A1.75 1.75 0 0 0 13.225 18h.526a.75.75 0 0 0 0-1.5h-.255a.25.25 0 0 1-.243-.31l.71-2.836A1.75 1.75 0 0 0 12.265 11H11.25Zm.75-3.75a1.125 1.125 0 1 0 0 2.25 1.125 1.125 0 0 0 0-2.25Z"
clipRule="evenodd"
/>
</svg>
)
const CloseIcon = (props: IconProps) => (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
{...props}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
)
export type ToastIntent = "default" | "success" | "warning" | "danger" | "info"
export interface ToastOptions {
/** Headline text or node. */
title: React.ReactNode
/** Optional secondary line below the title. */
description?: React.ReactNode
/** Status colour ramp. */
intent?: ToastIntent
/** Auto-dismiss after N ms. 0 keeps the toast until dismissed. Default 4000. */
duration?: number
}
interface ToastRecord extends Required<Omit<ToastOptions, "description">> {
id: string
description?: React.ReactNode
}
export type ToastPosition =
| "top-left"
| "top-center"
| "top-right"
| "bottom-left"
| "bottom-center"
| "bottom-right"
interface ToastApi {
(options: ToastOptions): string
success: (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "intent">) => string
warning: (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "intent">) => string
error: (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "intent">) => string
info: (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "intent">) => string
dismiss: (id: string) => void
}
const ToastContext = createContext<ToastApi | null>(null)
/** Imperative access to the toast queue. Must be used under a ToastProvider. */
export function useToast(): ToastApi {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error("useToast must be used within a <ToastProvider>")
return ctx
}
const iconMap = {
default: null,
success: CheckCircleIcon,
warning: ExclamationTriangleIcon,
danger: XCircleIcon,
info: InformationCircleIcon,
} as const
const toastStyles = tv({
base: [
"pointer-events-auto relative flex w-full max-w-sm items-start gap-3 overflow-hidden",
// `quebi-on-overlay` swaps the state tokens for their light values: the
// overlay is a dark ground in both themes.
"quebi-on-overlay rounded-(--q-radius-control) bg-quebi-overlay p-4 shadow-quebi-overlay",
"text-sm/5 text-pretty text-quebi-on-overlay/80",
// Links and actions inside a toast sit on a dark ground: the inverse signal.
"**:[a]:text-quebi-signal-inverse **:[a]:underline",
"forced-colors:border forced-colors:border-[CanvasText]",
// The intent rule: a 2px bar on the leading edge, clipped to the radius.
"before:absolute before:inset-y-0 before:start-0 before:w-0.5",
"transition-all duration-200 ease-out",
],
variants: {
intent: {
default: "before:hidden",
success: "before:bg-quebi-success *:data-[slot=toast-icon]:text-quebi-success",
warning: "before:bg-quebi-warn *:data-[slot=toast-icon]:text-quebi-warn",
danger: "before:bg-quebi-danger *:data-[slot=toast-icon]:text-quebi-danger",
info: "before:bg-quebi-on-overlay",
},
},
defaultVariants: { intent: "default" },
})
const positionStyles: Record<ToastPosition, string> = {
"top-left": "top-0 left-0 items-start",
"top-center": "top-0 left-1/2 -translate-x-1/2 items-center",
"top-right": "top-0 right-0 items-end",
"bottom-left": "bottom-0 left-0 items-start",
"bottom-center": "bottom-0 left-1/2 -translate-x-1/2 items-center",
"bottom-right": "bottom-0 right-0 items-end",
}
function ToastItem({ toast, onDismiss }: { toast: ToastRecord; onDismiss: (id: string) => void }) {
const Icon = iconMap[toast.intent]
useEffect(() => {
if (!toast.duration) return
const timer = setTimeout(() => onDismiss(toast.id), toast.duration)
return () => clearTimeout(timer)
}, [toast.id, toast.duration, onDismiss])
return (
<div data-slot="toast" className={cn(toastStyles({ intent: toast.intent }))}>
{Icon && <Icon data-slot="toast-icon" className="mt-px size-5 shrink-0 text-quebi-on-overlay" />}
<div className="min-w-0 flex-1">
{/* Words stay the overlay's text on every intent: the hue is the rule's,
so a warning reads as a warning without a sentence set in amber. */}
<div className="font-medium text-quebi-on-overlay">{toast.title}</div>
{toast.description && <div className="mt-1">{toast.description}</div>}
</div>
<Button
aria-label="Dismiss notification"
onPress={() => onDismiss(toast.id)}
className={cn(
"-mr-1 -mt-1 shrink-0 cursor-pointer rounded-(--q-radius-mark) p-1 text-quebi-on-overlay/70",
"transition-colors duration-150 hover:bg-quebi-on-overlay/10 hover:text-quebi-on-overlay",
"outline-none focus-visible:ring-2 focus-visible:ring-quebi-signal-inverse",
)}
>
<CloseIcon className="size-4" />
</Button>
</div>
)
}
export interface ToastProviderProps {
children: React.ReactNode
/** Where the stack docks on screen. Default "bottom-right". */
position?: ToastPosition
/** Default auto-dismiss for toasts that don't set one. Default 4000ms. */
duration?: number
}
let counter = 0
export function ToastProvider({
children,
position = "bottom-right",
duration = 4000,
}: ToastProviderProps) {
const [toasts, setToasts] = useState<ToastRecord[]>([])
const mounted = useRef(false)
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
}
}, [])
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const api = useMemo<ToastApi>(() => {
const push = (options: ToastOptions) => {
const id = `toast-${++counter}`
setToasts((prev) => [
...prev,
{
id,
title: options.title,
description: options.description,
intent: options.intent ?? "default",
duration: options.duration ?? duration,
},
])
return id
}
const fn = ((options: ToastOptions) => push(options)) as ToastApi
fn.success = (title, opts) => push({ ...opts, title, intent: "success" })
fn.warning = (title, opts) => push({ ...opts, title, intent: "warning" })
fn.error = (title, opts) => push({ ...opts, title, intent: "danger" })
fn.info = (title, opts) => push({ ...opts, title, intent: "info" })
fn.dismiss = dismiss
return fn
}, [duration, dismiss])
const isTop = position.startsWith("top")
const viewport =
typeof document !== "undefined"
? createPortal(
// A named <section> is the region landmark, so the role is the
// element rather than an attribute on a div.
<section
aria-label="Notifications"
className={cn(
"pointer-events-none fixed z-50 flex w-full max-w-sm flex-col gap-3 p-4",
isTop ? "flex-col" : "flex-col-reverse",
positionStyles[position],
)}
>
<div aria-live="polite" aria-atomic="false" className="sr-only">
{toasts.map((t) => (
<div key={t.id}>{t.title}</div>
))}
</div>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
))}
</section>,
document.body,
)
: null
return (
<ToastContext.Provider value={api}>
{children}
{viewport}
</ToastContext.Provider>
)
}