toasts with sonner's API: a stack at the window's foot, actions, promises
npx shadcn add @ag/toastAlready installed? Preview an update
npx shadcn add @ag/toast --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-11
- Fixed: toasts show again outside fullscreen. With no element fullscreen and no
container, the stack was handed to Base UI's portal asnull, which it reads as "wait for a container", so nothing was ever drawn; it now goes to the document's body. - Changed: one
Toasterper page. Everytoast()goes through one manager, so a second Toaster drew and announced each toast twice; mounting a second one now throws. Migration: keep the one in your layout, remove any other. - New:
containeronToaster. While an element is fullscreen the stack is drawn inside it, so a toast raised during a fullscreen video shows over it; a layer that covers the page without the browser's fullscreen (a player filling the window) is passed ascontainer. - Fixed:
toast.promise's outcome stays as long as the Toaster'stimeoutsays, not a fixed 5 s. - Changed: a loading toast's spinner turns for 30 s at most, then holds still while the toast stays.
- Changed: the action and the dismiss are
Buttons (secondary, and a ghost icon) in the toast's own material and scheme, so a glass toast's verb is glass too.toastnow installs@ag/button.
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import type { Material } from "@/registry/base-nova/ui/material"
import { Toaster, toast } from "@/registry/base-nova/ui/toast"
// #region save
/** Something that takes a moment and sometimes fails. */
const save = () =>
new Promise<number>((resolve, reject) =>
setTimeout(
() =>
Math.random() > 0.3
? resolve(35)
: reject(new Error("the network went away")),
1500,
),
)
// #endregion
export default function ToastDemo() {
// One Toaster on a page: every toast() goes through one manager, so a second
// Toaster would draw each toast twice. The switch changes this one's material.
const [material, setMaterial] = React.useState<Material>("plain")
return (
<div className="space-y-8">
<Sample
name="kinds"
glance
title="every kind, one stack"
description="hover the stack to open it and hold the timers; swipe one down or sideways to dismiss it"
with="save"
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => toast("tag renamed")}>
note
</Button>
<Button
variant="secondary"
onClick={() =>
toast.success("35 frames read", {
description: "smile found in 4 of them",
})
}
>
success
</Button>
<Button
variant="secondary"
onClick={() =>
toast.warning("the archive disk is offline", {
description: "its files play again once it is back",
})
}
>
warning
</Button>
<Button
variant="secondary"
onClick={() =>
toast.error("the upload stopped", {
description: "at 62%",
action: { label: "retry", onClick: () => toast("retrying") },
})
}
>
error, with an action
</Button>
<Button
variant="secondary"
onClick={() =>
toast.promise(save(), {
loading: "saving…",
success: (n) => `saved ${n} frames`,
error: (e) => (e as Error).message,
})
}
>
a promise
</Button>
</div>
<div className="flex items-center gap-2 text-muted-foreground text-xs">
material
{(["plain", "glass"] as const).map((m) => (
<Button
key={m}
size="sm"
variant="ghost"
pressed={material === m}
onClick={() => setMaterial(m)}
>
{m}
</Button>
))}
</div>
<Toaster material={material} />
</div>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// Toasts: a word about something that just happened, at the window's foot, gone on its
// own. sonner's API over Base UI's toast (announced to a screen reader, politely or,
// for an error, at once; F6 reaches them from the keyboard):
//
// <Toaster /> once, anywhere in the app
// toast("saved") a note
// toast.success("35 frames read", { description: "smile in 4" })
// toast.error("the upload stopped", { action: { label: "retry", onClick } })
// toast.loading("reading…") stays until updated or dismissed
// toast.promise(save(), { loading: "saving…", success: "saved", error: (e) => e.message })
// toast.dismiss(id) one, or every one with no id
//
// `toast()` can be called from anywhere, inside React or out (an action, a fetch's
// catch): the Toaster draws whatever it is handed. A kind wears ONE colour (its icon,
// the glass's stain), the words stay in the text's colour.
//
// The stack: the newest in front, the ones behind peeking out above it and shrinking;
// under the hand (or with keyboard focus) the stack opens into a list, and the timers
// wait. A swipe down or sideways dismisses one, following the finger. A toast past the
// limit (3) leaves. Nothing moves under reduced motion. Two materials (`material` on
// the Toaster, or a MaterialProvider round it): plain, the page's popover surface;
// glass, the material's glass.
//
// ONE Toaster per page: every `toast()` goes through one manager, so a second Toaster
// would draw and announce each toast twice; mounting a second one throws. It draws in
// the document's body, or inside whatever element is fullscreen while one is (a toast
// raised during a fullscreen video is drawn over it, not behind it), or in the
// `container` it is given (a player filling the window without the browser's
// fullscreen: pass its layer). A loading toast turns for SPIN_MS at most, then holds
// still: nothing animates forever.
import { Toast as Base } from "@base-ui/react/toast"
import { CircleAlert, CircleCheck, TriangleAlert, X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/base-nova/ui/button"
import {
type Material,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import {
glassStyle,
glassVariants,
schemeOf,
} from "@/registry/base-nova/ui/material-glass"
import { Spinner } from "@/registry/base-nova/ui/spinner"
export type ToastKind = "default" | "success" | "warning" | "error" | "loading"
export interface ToastOptions {
description?: React.ReactNode
/** A verb the reader can answer with (undo, retry): pressing it also dismisses. */
action?: { label: React.ReactNode; onClick: () => void }
/** ms before it goes on its own; 0 keeps it. Default 5000 (a loading one: 0). */
timeout?: number
/** Its id: a toast with the id of one shown replaces it in place. */
id?: string
}
/** The one manager every `toast()` goes through and the Toaster draws. */
const manager = Base.createToastManager()
function add(kind: ToastKind, title: React.ReactNode, o: ToastOptions = {}) {
return manager.add({
id: o.id,
title,
type: kind,
description: o.description,
timeout: o.timeout ?? (kind === "loading" ? 0 : undefined),
priority: kind === "error" ? "high" : "low",
actionProps: o.action && {
children: o.action.label,
onClick: o.action.onClick,
},
})
}
/** What a promise says at each step: a title, or one made from its value. */
type Said<V> = React.ReactNode | ((value: V) => React.ReactNode)
const say = <V,>(s: Said<V>, v: V): React.ReactNode =>
typeof s === "function" ? (s as (value: V) => React.ReactNode)(v) : s
/** A toast: `toast("saved")`, or one of a kind (`toast.success`, …). Each returns its
* id, for `toast.dismiss` or for replacing it (`id` in its options). */
export const toast = Object.assign(
(title: React.ReactNode, options?: ToastOptions) =>
add("default", title, options),
{
success: (title: React.ReactNode, options?: ToastOptions) =>
add("success", title, options),
warning: (title: React.ReactNode, options?: ToastOptions) =>
add("warning", title, options),
error: (title: React.ReactNode, options?: ToastOptions) =>
add("error", title, options),
loading: (title: React.ReactNode, options?: ToastOptions) =>
add("loading", title, options),
/** One toast for a promise: loading while it runs, then what it came to. The
* promise is handed back, so the caller still awaits its value. */
promise: <V,>(
promise: Promise<V>,
said: {
loading: React.ReactNode
success: Said<V>
error: Said<unknown>
},
) =>
// What it came to stays as long as the Toaster keeps any toast (its `timeout`).
manager.promise(promise, {
loading: { title: said.loading, type: "loading" },
success: (v: V) => ({
title: say(said.success, v),
type: "success",
}),
error: (e: unknown) => ({
title: say(said.error, e),
type: "error",
priority: "high",
}),
}),
/** Dismisses one toast, or every one when no id is given. */
dismiss: (id?: string) => manager.close(id),
},
)
/** Each kind's colour, as the tokens name it; default is the text's own. */
const INK: Record<ToastKind, string> = {
default: "currentColor",
loading: "currentColor",
success: "var(--tone-ok)",
warning: "var(--tone-warn)",
error: "var(--tone-bad)",
}
/** How long a loading toast's spinner turns before it holds still, ms: work that
* takes longer is still under way, said by the words, not by a wheel turning forever. */
const SPIN_MS = 30_000
const ICON: Record<Exclude<ToastKind, "loading">, React.ReactNode> = {
default: null,
success: <CircleCheck />,
warning: <TriangleAlert />,
error: <CircleAlert />,
}
/** A loading toast's spinner: turning for SPIN_MS from when this toast began loading,
* then still. */
function LoadingMark() {
const [still, setStill] = React.useState(false)
React.useEffect(() => {
const t = window.setTimeout(() => setStill(true), SPIN_MS)
return () => window.clearTimeout(t)
}, [])
return <Spinner size="sm" className={cn(still && "[&_svg]:animate-none")} />
}
/** The element fullscreen right now, which the toasts must be drawn inside to be seen:
* the browser draws nothing of the page over it. Null otherwise. */
function useFullscreenElement(): Element | null {
const [el, setEl] = React.useState<Element | null>(null)
React.useEffect(() => {
const read = () => setEl(document.fullscreenElement)
read()
document.addEventListener("fullscreenchange", read)
return () => document.removeEventListener("fullscreenchange", read)
}, [])
return el
}
/** Toasters mounted now: one at most (the header says why). */
let mounted = 0
const kindOf = (type: string | undefined): ToastKind =>
type === "success" ||
type === "warning" ||
type === "error" ||
type === "loading"
? type
: "default"
/** Where the stack stands: the window's foot, at its end or its middle. */
export type ToasterPosition = "bottom-end" | "bottom-center"
const VIEWPORT: Record<ToasterPosition, string> = {
"bottom-end":
"fixed right-4 bottom-4 z-[60] w-[min(22.5rem,calc(100vw-2rem))] sm:right-6 sm:bottom-6",
"bottom-center":
"fixed bottom-4 left-1/2 z-[60] w-[min(22.5rem,calc(100vw-2rem))] -translate-x-1/2 sm:bottom-6",
}
/** THE STACK, Base UI's own variables: the frontmost at the foot, each one behind it
* lifted by the peek and shrunk a tenth, all of them as tall as the frontmost while
* stacked; opened (`data-expanded`), each at its own offset and height. A swipe
* carries the toast with the finger, a dismissal sends it out the way it went. */
const STACK =
"[--gap:0.625rem] [--peek:0.625rem] [--scale:calc(max(0,1-(var(--toast-index)*0.08)))] [--shrink:calc(1-var(--scale))] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] absolute right-0 bottom-0 left-0 z-[calc(1000-var(--toast-index))] h-(--height) origin-bottom select-none [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-[''] data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))] data-limited:opacity-0 data-starting-style:[transform:translateY(150%)] data-ending-style:opacity-0 [&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)] data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))] data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))] data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]"
/** The stack's motion, one per face: the plain face's own transition, the glass
* face's through the material, which owns its transition (`--glass-moves`). */
const STACK_MOTION: Record<Material, string> = {
plain:
"[transition:transform_0.5s_cubic-bezier(0.22,1,0.36,1),opacity_0.5s,height_0.15s] motion-reduce:transition-none",
glass:
"[--glass-moves:transform_0.5s_cubic-bezier(0.22,1,0.36,1),opacity_0.5s,height_0.15s]",
}
const PLAIN_FACE =
"rounded-xl border border-border bg-popover text-popover-foreground shadow-lg"
const GLASS_FACE = cn(
glassVariants({ shape: "surface" }),
"[--glass-radius:--theme(--radius-2xl)] [--glass-blur:18px]",
)
/** The stack, drawn: once per page, anywhere in the app. */
export function Toaster({
position = "bottom-end",
limit = 3,
timeout = 5000,
material,
scheme,
container,
}: {
position?: ToasterPosition
/** How many show at once; older ones leave. */
limit?: number
/** ms a toast stays by default (a promise's outcome included); 0 keeps them. */
timeout?: number
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
/** Where the stack is drawn. Default: the body, or the fullscreen element while
* there is one. A layer that covers the page without being fullscreen (a player
* filling the window) is passed here, or its toasts are drawn behind it. */
container?: React.ComponentProps<typeof Base.Portal>["container"]
}) {
React.useEffect(() => {
mounted += 1
if (mounted > 1) {
mounted -= 1
throw new Error(
"a second <Toaster> is mounted: every toast() goes to one manager, so it would be drawn and announced twice; mount one Toaster per page",
)
}
return () => {
mounted -= 1
}
}, [])
const fullscreen = useFullscreenElement()
return (
<Base.Provider toastManager={manager} limit={limit} timeout={timeout}>
{/* Never an explicit null: Base UI's portal reads null as "wait for a
container" and draws nothing, so no fullscreen element means the body. */}
<Base.Portal
container={container ?? (fullscreen as HTMLElement | null) ?? undefined}
>
<Base.Viewport
data-slot="toaster"
className={cn(VIEWPORT[position], "outline-none")}
>
<Toasts material={material} scheme={scheme} />
</Base.Viewport>
</Base.Portal>
</Base.Provider>
)
}
function Toasts({
material,
scheme,
}: {
material?: Material
scheme?: Scheme
}) {
const { toasts } = Base.useToastManager()
const m = useMaterial(material)
return toasts.map((t) => (
<ToastCard key={t.id} toast={t} material={m} scheme={scheme} />
))
}
/** One toast, a surface of its own: over a picture (a player's, in fullscreen) it is
* read by the part of it under the toast, and its buttons wear what it reads. */
function ToastCard({
toast: t,
material: m,
scheme,
}: {
toast: Base.Root.ToastObject
material: Material
scheme?: Scheme
}) {
const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme)
const kind = kindOf(t.type)
const ink = INK[kind]
return (
<Base.Root
ref={here}
toast={t}
data-slot="toast"
data-kind={kind}
data-material={m}
className={cn(
schemeOf(s),
m === "glass" ? GLASS_FACE : PLAIN_FACE,
STACK,
STACK_MOTION[m],
)}
style={
{
"--ag-toast-ink": ink,
...(m === "glass" && kind !== "default" && kind !== "loading"
? glassStyle({ tint: ink })
: {}),
} as React.CSSProperties
}
>
<Base.Content className="flex items-start gap-3 overflow-hidden px-4 py-3 text-sm transition-opacity duration-250 data-behind:opacity-0 data-expanded:opacity-100">
{(kind === "loading" || ICON[kind]) && (
<span
aria-hidden
className="mt-0.5 grid size-4 shrink-0 place-items-center text-(--ag-toast-ink) [&_svg]:size-4"
>
{kind === "loading" ? <LoadingMark key={t.id} /> : ICON[kind]}
</span>
)}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<Base.Title className="font-medium leading-5" />
<Base.Description className="text-current/65 leading-5" />
</div>
{t.actionProps && (
<Base.Action
render={
<Button
variant="secondary"
size="sm"
material={m}
scheme={s}
className="-my-1"
/>
}
/>
)}
<Base.Close
aria-label="dismiss"
render={
<Button
variant="ghost"
size="icon-sm"
corners="pill"
material={m}
scheme={s}
className="-mt-1 -mr-2 text-current/45 hover:text-current"
/>
}
>
<X />
</Base.Close>
</Base.Content>
</Base.Root>
)
}