Toast
A transient notification — raised imperatively, announced to assistive tech, and dismissed on a timer, by swipe, or with a button. Toasts stack into a clean peek at the screen corner and fan out on hover, each led by an optional status icon.
Basic
Wrap the tree in a Toast.Provider, render a Toast.Viewport that maps useToast().toasts into Toast.Roots, and raise a toast from anywhere under the provider with the manager. Toast.Content stacks the Title and Description; Toast.Close dismisses it. The provider runs the auto-dismiss timer (5 seconds by default) and announces neutral/info/success/loading toasts politely, while warning/danger toasts use an assertive live region.
"use client";
import { Button, Toast, useToast } from "@stridge/noctis";
function Trigger() {
const toast = useToast();
return (
<Button
variant="outline"
onClick={() => toast.show({ title: "Link copied", description: "The page URL is on your clipboard." })}
>
Show toast
</Button>
);
}
function Toasts() {
const toast = useToast();
return (
<Toast.Viewport>
{toast.toasts.map((entry) => (
<Toast.Root key={entry.id} toast={entry}>
<Toast.Content>
<Toast.Title>{entry.title}</Toast.Title>
{entry.description ? <Toast.Description>{entry.description}</Toast.Description> : null}
<Toast.Close aria-label="Dismiss">×</Toast.Close>
</Toast.Content>
</Toast.Root>
))}
</Toast.Viewport>
);
}
export default function ToastBasic() {
return (
<Toast.Provider>
<Trigger />
<Toasts />
</Toast.Provider>
);
}
Status accents
The typed helpers — info, success, warning, and danger — set the toast's type, which paints any Toast.Icon in the matching status role. The indigo accent is reserved for signal, so a plain show toast carries a neutral icon, not the accent — reach for a status only when the message is genuinely an outcome. A leading icon is the canonical, scannable status cue; map each type to its glyph in your renderer.
"use client";
import { Button, Icon, type IconGlyph, Toast, useToast } from "@stridge/noctis";
import { CircleAlert, CircleCheck, Info, TriangleAlert } from "lucide-react";
// The canonical, scannable cue: a per-type leading glyph that inherits the toast's status colour.
const STATUS_ICONS: Record<string, IconGlyph> = {
info: Info,
success: CircleCheck,
warning: TriangleAlert,
danger: CircleAlert,
};
function Triggers() {
const toast = useToast();
return (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => toast.info("Sync started", { description: "Pulling the latest changes." })}>
Info
</Button>
<Button variant="outline" onClick={() => toast.success("Saved", { description: "Your changes are live." })}>
Success
</Button>
<Button variant="outline" onClick={() => toast.warning("Storage almost full", { description: "92% used." })}>
Warning
</Button>
<Button variant="outline" onClick={() => toast.danger("Upload failed", { description: "The connection dropped." })}>
Danger
</Button>
</div>
);
}
function Toasts() {
const toast = useToast();
return (
<Toast.Viewport>
{toast.toasts.map((entry) => {
const glyph = entry.type ? STATUS_ICONS[entry.type] : undefined;
return (
<Toast.Root key={entry.id} toast={entry}>
<Toast.Content>
{glyph ? (
<Toast.Icon>
<Icon icon={glyph} />
</Toast.Icon>
) : null}
<Toast.Title>{entry.title}</Toast.Title>
{entry.description ? <Toast.Description>{entry.description}</Toast.Description> : null}
<Toast.Close aria-label="Dismiss">×</Toast.Close>
</Toast.Content>
</Toast.Root>
);
})}
</Toast.Viewport>
);
}
export default function ToastStatuses() {
return (
<Toast.Provider>
<Triggers />
<Toasts />
</Toast.Provider>
);
}
Leading icon
Toast.Icon is the optional leading slot — the toast's single chromatic signal. Compose the glyph as its child and it inherits the toast's status colour centrally, so the icon is the one accent, never two. It is decorative (aria-hidden); the title carries the accessible message.
"use client";
import { Button, Icon, Toast, useToast } from "@stridge/noctis";
import { CircleCheck } from "lucide-react";
function Trigger() {
const toast = useToast();
return (
<Button variant="outline" onClick={() => toast.success("Deployment live", { description: "v2.4.0 is serving traffic." })}>
Deploy
</Button>
);
}
function Toasts() {
const toast = useToast();
return (
<Toast.Viewport>
{toast.toasts.map((entry) => (
<Toast.Root key={entry.id} toast={entry}>
<Toast.Content>
{/* `Toast.Icon` inherits the toast's status colour, so the glyph reads as the signal. */}
<Toast.Icon>
<Icon icon={CircleCheck} />
</Toast.Icon>
<Toast.Title>{entry.title}</Toast.Title>
{entry.description ? <Toast.Description>{entry.description}</Toast.Description> : null}
<Toast.Close aria-label="Dismiss">×</Toast.Close>
</Toast.Content>
</Toast.Root>
))}
</Toast.Viewport>
);
}
export default function ToastWithIcon() {
return (
<Toast.Provider>
<Trigger />
<Toasts />
</Toast.Provider>
);
}
Loading & promise
useToast().promise(p, { loading, success, error }) drives one toast through an async flow: it shows the loading message with Toast.Spinner, then swaps that same toast to the success or error result as the promise settles — never stacking a single flow. The success/error mappers receive the resolved value or the error.
"use client";
import { Button, Icon, type IconGlyph, Toast, useToast } from "@stridge/noctis";
import { CircleAlert, CircleCheck } from "lucide-react";
// `promise()` stamps `loading`, then `success` or `error` on the same toast as it settles.
const RESULT_ICONS: Record<string, IconGlyph> = {
success: CircleCheck,
error: CircleAlert,
};
function Trigger() {
const toast = useToast();
function run() {
const work = new Promise<string>((resolve, reject) => {
const ok = Math.random() > 0.3;
setTimeout(() => (ok ? resolve("3 files") : reject(new Error("the connection dropped"))), 1800);
});
toast.promise(work, {
loading: "Uploading…",
success: (count) => `Uploaded ${count}`,
error: (err: Error) => `Upload failed: ${err.message}`,
});
}
return (
<Button variant="outline" onClick={run}>
Upload
</Button>
);
}
function Toasts() {
const toast = useToast();
return (
<Toast.Viewport>
{toast.toasts.map((entry) => {
const glyph = entry.type ? RESULT_ICONS[entry.type] : undefined;
return (
<Toast.Root key={entry.id} toast={entry}>
<Toast.Content>
{entry.type === "loading" ? (
<Toast.Spinner />
) : glyph ? (
<Toast.Icon>
<Icon icon={glyph} />
</Toast.Icon>
) : null}
<Toast.Title>{entry.title}</Toast.Title>
{entry.description ? <Toast.Description>{entry.description}</Toast.Description> : null}
<Toast.Close aria-label="Dismiss">×</Toast.Close>
</Toast.Content>
</Toast.Root>
);
})}
</Toast.Viewport>
);
}
export default function ToastPromise() {
return (
<Toast.Provider>
<Trigger />
<Toasts />
</Toast.Provider>
);
}
For a flow you settle yourself, loading(title) raises a persistent loading toast (no auto-dismiss); resolve it in place with update(id, …) or close(id). The spinner is neutral and goes static under reduced motion.
"use client";
import { Button, Icon, Toast, useToast } from "@stridge/noctis";
import { CircleCheck } from "lucide-react";
function Trigger() {
const toast = useToast();
function save() {
// A `loading` toast is persistent (no auto-dismiss) — settle it in place with `update`.
const id = toast.loading("Saving draft…");
setTimeout(() => {
toast.update(id, { type: "success", title: "Draft saved", timeout: 5000 });
}, 1600);
}
return (
<Button variant="outline" onClick={save}>
Save draft
</Button>
);
}
function Toasts() {
const toast = useToast();
return (
<Toast.Viewport>
{toast.toasts.map((entry) => (
<Toast.Root key={entry.id} toast={entry}>
<Toast.Content>
{entry.type === "loading" ? (
<Toast.Spinner />
) : entry.type === "success" ? (
<Toast.Icon>
<Icon icon={CircleCheck} />
</Toast.Icon>
) : null}
<Toast.Title>{entry.title}</Toast.Title>
{entry.description ? <Toast.Description>{entry.description}</Toast.Description> : null}
<Toast.Close aria-label="Dismiss">×</Toast.Close>
</Toast.Content>
</Toast.Root>
))}
</Toast.Viewport>
);
}
export default function ToastLoading() {
return (
<Toast.Provider>
<Trigger />
<Toasts />
</Toast.Provider>
);
}
Actions & persistence
A toast can carry an inline Toast.Action and a Toast.Close — compose each from a Button through render to inherit the button's look and behaviour. A toast with an action must stay reachable, so pass timeout: 0 to keep it up until acted on (an auto-dismissing toast can vanish before a slow reader reacts). Any caller timeout below the 5-second floor is raised to it.
"use client";
import { Button, Icon, Toast, useToast } from "@stridge/noctis";
import { X } from "lucide-react";
function Trigger() {
const toast = useToast();
return (
<Button
variant="outline"
onClick={() =>
toast.show({
title: "Message archived",
description: "It moved to the archive.",
// A persistent toast — `timeout: 0` disables the auto-dismiss so the action stays reachable.
timeout: 0,
})
}
>
Archive
</Button>
);
}
function Toasts() {
const toast = useToast();
return (
<Toast.Viewport>
{toast.toasts.map((entry) => (
<Toast.Root key={entry.id} toast={entry}>
<Toast.Content>
<Toast.Title>{entry.title}</Toast.Title>
{entry.description ? <Toast.Description>{entry.description}</Toast.Description> : null}
<Toast.Action render={<Button variant="secondary" size="xs" onClick={() => toast.dismiss(entry.id)} />}>
Undo
</Toast.Action>
<Toast.Close render={<Button variant="ghost" size="xs" iconOnly aria-label="Dismiss" />}>
<Icon icon={X} size="sm" />
</Toast.Close>
</Toast.Content>
</Toast.Root>
))}
</Toast.Viewport>
);
}
export default function ToastWithAction() {
return (
<Toast.Provider>
<Trigger />
<Toasts />
</Toast.Provider>
);
}
The canonical destructive pattern pairs the action with Undo: delete optimistically, raise a persistent toast (timeout: 0) whose action restores the item. Base UI fills Toast.Action from the toast's actionProps.
"use client";
import { Button, Icon, Toast, useToast } from "@stridge/noctis";
import { Trash2, X } from "lucide-react";
function Trigger() {
const toast = useToast();
function remove() {
// A destructive action pairs with a persistent Undo: `timeout: 0` keeps the toast up until acted on.
const id = toast.show({
title: "Issue deleted",
description: "“Fix the login redirect” was removed.",
timeout: 0,
actionProps: {
children: "Undo",
onClick() {
toast.dismiss(id);
toast.success("Restored", { description: "The issue is back." });
},
},
});
}
return (
<Button variant="outline" onClick={remove}>
<Icon icon={Trash2} size="sm" />
Delete issue
</Button>
);
}
function Toasts() {
const toast = useToast();
return (
<Toast.Viewport>
{toast.toasts.map((entry) => (
<Toast.Root key={entry.id} toast={entry}>
<Toast.Content>
<Toast.Title>{entry.title}</Toast.Title>
{entry.description ? <Toast.Description>{entry.description}</Toast.Description> : null}
{entry.actionProps ? <Toast.Action /> : null}
<Toast.Close render={<Button variant="ghost" size="xs" iconOnly aria-label="Dismiss" />}>
<Icon icon={X} size="sm" />
</Toast.Close>
</Toast.Content>
</Toast.Root>
))}
</Toast.Viewport>
);
}
export default function ToastUndo() {
return (
<Toast.Provider>
<Trigger />
<Toasts />
</Toast.Provider>
);
}
Placement
Toast.Viewport docks via placement (default bottom-end) — a logical corner, so *-start/*-end flip under RTL. Top placements enter from above and stack downward; bottom placements lift off the docked edge. The insets are tokenised through --noctis-toast-viewport-padding.
"use client";
import { Button, Toast, type ToastPlacement, useToast } from "@stridge/noctis";
import { useState } from "react";
const PLACEMENTS: ToastPlacement[] = ["top-start", "top-center", "top-end", "bottom-start", "bottom-center", "bottom-end"];
function Triggers({ onPick }: { onPick: (placement: ToastPlacement) => void }) {
const toast = useToast();
return (
<div className="grid w-full max-w-sm grid-cols-3 gap-2">
{PLACEMENTS.map((placement) => (
<Button
key={placement}
variant="outline"
size="sm"
onClick={() => {
onPick(placement);
toast.info(placement, { description: "Docked here." });
}}
>
{placement}
</Button>
))}
</div>
);
}
function Toasts({ placement }: { placement: ToastPlacement }) {
const toast = useToast();
return (
<Toast.Viewport placement={placement}>
{toast.toasts.map((entry) => (
<Toast.Root key={entry.id} toast={entry}>
<Toast.Content>
<Toast.Title>{entry.title}</Toast.Title>
{entry.description ? <Toast.Description>{entry.description}</Toast.Description> : null}
<Toast.Close aria-label="Dismiss">×</Toast.Close>
</Toast.Content>
</Toast.Root>
))}
</Toast.Viewport>
);
}
export default function ToastPositions() {
const [placement, setPlacement] = useState<ToastPlacement>("bottom-end");
return (
<Toast.Provider>
<Triggers onPick={setPlacement} />
<Toasts placement={placement} />
</Toast.Provider>
);
}
Anchored toasts
For contextual feedback pinned to a trigger (a transient "Copied" bubble), wrap each toast in Toast.Positioner and pass positionerProps: { anchor } when raising it. The positioner takes over placement (mirroring popup positioning), so the toast leaves the stacked corner and sizes to its content. Keep anchored toasts in their own viewport, separate from the stacked queue.
"use client";
import { Button, Icon, Toast, useToast } from "@stridge/noctis";
import { Copy } from "lucide-react";
import { useRef } from "react";
function CopyButton() {
const toast = useToast();
const anchorRef = useRef<HTMLSpanElement>(null);
return (
<span ref={anchorRef} className="inline-flex">
<Button
variant="outline"
onClick={() =>
// `positionerProps.anchor` pins this toast to the trigger instead of the stacked corner.
toast.show({
description: "Copied to clipboard",
timeout: 1500,
positionerProps: { anchor: anchorRef.current, side: "top", sideOffset: 8 },
})
}
>
<Icon icon={Copy} size="sm" />
Copy link
</Button>
</span>
);
}
function AnchoredToasts() {
const toast = useToast();
return (
<Toast.Portal>
<Toast.Viewport>
{toast.toasts.map((entry) => (
<Toast.Positioner key={entry.id} toast={entry}>
<Toast.Root toast={entry}>
<Toast.Content>
<Toast.Description>{entry.description}</Toast.Description>
</Toast.Content>
</Toast.Root>
</Toast.Positioner>
))}
</Toast.Viewport>
</Toast.Portal>
);
}
export default function ToastAnchored() {
return (
<Toast.Provider>
<CopyButton />
<AnchoredToasts />
</Toast.Provider>
);
}
Custom data
Pass any typed object as data when raising a toast and read it back in the renderer to drive custom content — a mention, a thumbnail, a progress payload. The data rides the toast object Base UI tracks.
"use client";
import { Button, Toast, useToast } from "@stridge/noctis";
interface MentionData {
user: string;
project: string;
}
function Trigger() {
const toast = useToast();
return (
<Button
variant="outline"
onClick={() =>
toast.show({
title: "New mention",
data: { user: "Ada Lovelace", project: "Q3 planning" } satisfies MentionData,
})
}
>
Mention me
</Button>
);
}
function Toasts() {
const toast = useToast();
return (
<Toast.Viewport>
{toast.toasts.map((entry) => {
// Any typed object passed as `data` is available here to drive custom rendering.
const data = entry.data as MentionData | undefined;
return (
<Toast.Root key={entry.id} toast={entry}>
<Toast.Content>
<Toast.Title>{entry.title}</Toast.Title>
{data ? (
<Toast.Description>
{data.user} mentioned you in “{data.project}”.
</Toast.Description>
) : null}
<Toast.Close aria-label="Dismiss">×</Toast.Close>
</Toast.Content>
</Toast.Root>
);
})}
</Toast.Viewport>
);
}
export default function ToastCustomData() {
return (
<Toast.Provider>
<Trigger />
<Toasts />
</Toast.Provider>
);
}
Varying heights
Toasts of different heights stack cleanly: Toast.Content clips overflow so the collapsed peek clamps every toast to the frontmost one's height, then each expands to its natural height as the stack fans out on hover or focus.
"use client";
import { Button, Toast, useToast } from "@stridge/noctis";
import { useState } from "react";
const MESSAGES = [
"Short message.",
"A slightly longer message that wraps onto a second line in the toast.",
"A much longer description that intentionally takes several lines, so you can see the collapsed stack clamp every toast to the front one's height and expand smoothly on hover or focus.",
];
function Trigger() {
const toast = useToast();
const [n, setN] = useState(0);
return (
<Button
variant="outline"
onClick={() => {
toast.show({ title: `Toast ${n + 1}`, description: MESSAGES[n % MESSAGES.length] });
setN((prev) => prev + 1);
}}
>
Add toast
</Button>
);
}
function Toasts() {
const toast = useToast();
return (
<Toast.Viewport>
{toast.toasts.map((entry) => (
<Toast.Root key={entry.id} toast={entry}>
<Toast.Content>
<Toast.Title>{entry.title}</Toast.Title>
{entry.description ? <Toast.Description>{entry.description}</Toast.Description> : null}
<Toast.Close aria-label="Dismiss">×</Toast.Close>
</Toast.Content>
</Toast.Root>
))}
</Toast.Viewport>
);
}
export default function ToastVaryingHeights() {
return (
<Toast.Provider>
<Trigger />
<Toasts />
</Toast.Provider>
);
}
Deduplicating
Raising a toast with an id that already exists updates it in place and refreshes its timer instead of stacking a duplicate — ideal for a recurring status like “Draft saved”. The toast's updateKey increments on each upsert, so a renderer can replay an attention cue.
"use client";
import { Button, Toast, useToast } from "@stridge/noctis";
function Trigger() {
const toast = useToast();
return (
<Button
variant="outline"
onClick={() =>
// Re-adding with the same `id` updates the live toast in place (and refreshes its timer)
// instead of stacking a duplicate. `entry.updateKey` increments on each upsert.
toast.show({
id: "save-status",
title: "Draft saved",
description: "Click again while it is visible — it updates in place, never stacks.",
})
}
>
Save draft
</Button>
);
}
function Toasts() {
const toast = useToast();
return (
<Toast.Viewport>
{toast.toasts.map((entry) => (
<Toast.Root key={entry.id} toast={entry}>
<Toast.Content>
<Toast.Title>{entry.title}</Toast.Title>
{entry.description ? <Toast.Description>{entry.description}</Toast.Description> : null}
<Toast.Close aria-label="Dismiss">×</Toast.Close>
</Toast.Content>
</Toast.Root>
))}
</Toast.Viewport>
);
}
export default function ToastDeduplicated() {
return (
<Toast.Provider>
<Trigger />
<Toasts />
</Toast.Provider>
);
}
The manager
useToast() returns a typed façade over the live queue, callable from any component under Toast.Provider. Like Sheet's useSheetStack, the state is owned upstream (here, Base UI's provider) and reached through context.
toasts— the mounted toasts, front to back; map them intoToast.Roots inside the viewport.count— the number of mounted toasts (≈ Sheet'sdepth).show(options)— raise a neutral toast with the full option set (title,description,timeout,priority,data, …). A callertimeoutis floored to 5000ms; pass0to keep it up. Returns the toast's id.info/success/warning/danger(title, options?) — raise a status-typed toast; the title is positional.warning/dangerannounce assertively, the rest politely.loading(title, options?)— raise a persistent loading toast (renderToast.Spinnerfor it), then settle it withupdate/close.dismiss(id?)— dismiss a toast by id; omit the id to clear every toast (Base UI closes the whole stack).clear()— clear the whole stack at once (≈ Sheet'sreset()); equivalent todismiss()with no id.update(id, options)— update a live toast in place, refreshing its timer.promise(promise, { loading, success, error })— drive a single toast through a promise's lifecycle.
Accessibility
- Politeness follows status.
dangerandwarningare announced assertively (APGalert— they interrupt);info,success,loading, and neutral toasts are polite (status— they wait for idle). The manager maps this for you; for a customshow, setpriority: 'high'when the message is urgent. - Slow readers come first. Toasts never auto-dismiss faster than 5 seconds, and a toast with an action should set
timeout: 0so it stays reachable. Screen-zoom users can miss a toast entirely — keep critical information off transient surfaces. - Focus. F6 moves focus into the viewport and lands on the front toast with a visible ring; focus restores to the page when the last toast closes. Toasts are never tab-trapped.
- Copy conventions. Write success as
{Noun} {past-participle}(“Domain added”), sentence case, no trailing period; end errors with a recovery step. Keep to one tertiary action.
Keyboard
| Key | Action |
|---|---|
| F6 | Move focus into the toast viewport from anywhere on the page (lands on the front toast). |
| Tab / Shift + Tab | Move focus between a focused toast's action and close buttons. |
| Esc | With the viewport focused, dismiss the focused toast. |
| Enter / Space | Activate the focused action or close button. |
Toasts can also be dismissed by swipe — toward the docked corner (down, or to the end edge, which flips under RTL). Hovering or focusing the stack pauses every auto-dismiss timer and fans the toasts out so each is readable.
Anatomy
Compose a toast from its parts. Toast.Provider owns the queue and the timers (timeout, default 5000ms — 0 disables auto-dismiss; limit, default 3), and Toast.Viewport is the live region that holds the stack.
Toast.Provider— hosts the manager and timers, and resolves the translated viewport label. Renders no element of its own.Toast.Viewport— the fixed live region;placementdocks it (defaultbottom-end,data-placement) anddata-expandedmarks it fanned out.Toast.Root— groups one toast on an elevatedSurface. Pass thetoastobject fromuseToast().toasts; itstypedrives the leading status icon's colour (data-type). Swipe-to-dismiss runs toward the docked corner (RTL-resolved).Toast.Content— the card body: a grid of the leading icon, the title/description, and the close button. It holds everything visible, so the collapsed stack can cleanly blank the cards behind the front one (data-behind).Toast.Title+Toast.Description— the accessible name and supporting copy, linked to the root viaaria-labelledbyandaria-describedby.Toast.Icon— an optional leading status glyph that inherits the toast's colour (decorative).Toast.Spinner— the neutral loading spinner for theloadingphase (role="status").Toast.Action— an inline action under the copy; carries the status colour and a focus ring, or composes with aButtonthroughrender.Toast.Close— the dismiss button; give it anaria-label, or compose aButtonthroughrender.Toast.Portal/Toast.Positioner/Toast.Arrow— for anchored toasts pinned to a trigger.
Every rendered part carries a data-slot (noctis-toast-viewport, noctis-toast, noctis-toast-content, noctis-toast-title, noctis-toast-description, noctis-toast-icon, noctis-toast-spinner, noctis-toast-action, noctis-toast-close) for host-side styling — the root also carries data-type, the viewport data-placement. Pair it with the Base UI state attributes (data-expanded, data-behind, data-limited, data-swiping, data-swipe-direction, data-starting-style, data-ending-style). The root renders through Surface at elevated elevation, so controls inside re-derive off that base and separate cleanly.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every toast in that region retunes — e.g. .notifications { --noctis-toast-viewport-gap: 0.5rem; } tightens the gap between fanned-out toasts beneath it. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI's Toast list just the props they pass through. Expand a row for the full type and description.