Toast
A stack of transient notifications in the corner of the screen.
import { Div } from "@implementjs/core";
import { Button } from "@/lib/components/ui/button";
import { createToastManager, Toaster, type ToasterToastData } from "@/lib/components/ui/toast";
const manager = createToastManager();
function saveDocument() {
return new Promise<string>((resolve) => setTimeout(() => resolve("Quarterly report"), 2000));
}
export default function ToastDemo() {
return Div(
{ class: "flex flex-wrap items-center justify-center gap-2" },
Toaster({ manager }),
Button(
{
variant: "outline",
onClick: () =>
manager.add({
title: "Event created",
description: "Friday, August 21 at 4:00 PM",
}),
},
"Show toast",
),
Button(
{
variant: "outline",
onClick: () =>
manager.add({
type: "success",
title: "Changes saved",
}),
},
"Success",
),
Button(
{
variant: "outline",
onClick: () =>
manager.add({
type: "error",
title: "Something went wrong",
description: "The changes could not be saved.",
}),
},
"Error",
),
Button(
{
variant: "outline",
onClick: () =>
manager.add({
title: "Message archived",
data: {
action: {
label: "Undo",
onClick: () => manager.add({ title: "Message restored" }),
},
} satisfies ToasterToastData,
}),
},
"With action",
),
Button(
{
variant: "outline",
onClick: () =>
manager.promise(saveDocument(), {
loading: "Saving document…",
success: (name) => `${name} saved`,
error: "Could not save the document",
}),
},
"Promise",
),
);
}Installation
npx jsrepo add @implementjs/ui/toast
jsrepo pulls button along with it, and installs @implementjs/lucide.
Copy the file below to src/lib/components/ui/toast.ts. It imports cn from utils.ts, which belongs at src/lib/utils.ts, and button from the same directory — copy those in beside it too. Then, on top of @implementjs/core and @implementjs/primitives:
npm install @implementjs/lucide
import {
Div,
ForEach,
If,
Span,
type Child,
type ComponentProps,
type Readable,
} from "@implementjs/core";
import {
CircleAlertIcon,
CircleCheckIcon,
InfoIcon,
LoaderCircleIcon,
XIcon,
} from "@implementjs/lucide";
import {
createToastManager,
Toast as ToastPrimitive,
ToastAction as ToastActionPrimitive,
ToastClose as ToastClosePrimitive,
ToastDescription as ToastDescriptionPrimitive,
ToastPortal as ToastPortalPrimitive,
ToastProvider as ToastProviderPrimitive,
ToastTitle as ToastTitlePrimitive,
ToastViewport as ToastViewportPrimitive,
type ToastData,
type ToastManager,
} from "@implementjs/primitives";
import { buttonVariants } from "./button";
import { cn } from "@/lib/utils";
import { createComponent } from "@implementjs/primitives";
export { createToastManager, type ToastData, type ToastManager };
export type ToastProviderProps = ComponentProps<typeof ToastProviderPrimitive>;
export type ToastViewportProps = ComponentProps<typeof ToastViewportPrimitive>;
export type ToastRootProps = ComponentProps<typeof ToastPrimitive>;
export type ToastTitleProps = ComponentProps<typeof ToastTitlePrimitive>;
export type ToastDescriptionProps = ComponentProps<typeof ToastDescriptionPrimitive>;
export type ToastActionProps = ComponentProps<typeof ToastActionPrimitive>;
export type ToastCloseProps = ComponentProps<typeof ToastClosePrimitive>;
export const ToastProvider = ToastProviderPrimitive;
export const ToastPortal = ToastPortalPrimitive;
export const ToastViewport = createComponent(function ToastViewport(
{ class: className, ...props }: ToastViewportProps,
...children: Child[]
) {
return ToastViewportPrimitive(
{
...props,
"data-slot": "toast-viewport",
class: cn(
"fixed right-6 bottom-6 z-50 w-[360px] max-w-[calc(100vw-3rem)] outline-none",
className,
),
},
...children,
);
});
export const Toast = createComponent(function Toast(
{ class: className, ...props }: ToastRootProps,
...children: Child[]
) {
return ToastPrimitive(
{
...props,
"data-slot": "toast",
class: cn(
"absolute right-0 bottom-0 w-full touch-none rounded-lg border bg-background p-4 text-foreground shadow-lg outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"z-[calc(100-var(--toast-index))]",
// One transform for every state — the states only swap the variables it
// reads, so the transitions never fight over the property itself.
"[transform:translate3d(calc(var(--toast-swipe-movement-x)+var(--toast-exit-x)),calc(var(--toast-swipe-movement-y)+var(--toast-shift)+var(--toast-exit-y)),0)_scale(var(--toast-scale))]",
"[--toast-exit-x:0px] [--toast-exit-y:0px]",
// collapsed stack: peek out behind the front toast, slightly scaled down
"[--toast-shift:calc(var(--toast-index)*-1rem)] [--toast-scale:calc(1-var(--toast-index)*0.06)]",
// expanded stack: fan out by real heights
"data-expanded:[--toast-scale:1] data-expanded:[--toast-shift:calc(var(--toast-offset-y)*-1)]",
"transition-[transform,opacity] duration-350 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
// enter: rise in from below the stack
"starting:data-[state=open]:opacity-0 starting:data-[state=open]:[--toast-exit-y:calc(100%+1.5rem)]",
// exit: fade while sinking, or keep travelling in the swiped direction
"data-[state=closed]:pointer-events-none data-[state=closed]:opacity-0 data-[state=closed]:duration-200 data-[state=closed]:ease-in",
"data-[state=closed]:[--toast-exit-y:1rem]",
"data-[state=closed]:data-[swipe-direction=right]:[--toast-exit-x:calc(100%+1.5rem)] data-[state=closed]:data-[swipe-direction=right]:[--toast-exit-y:0px]",
"data-[state=closed]:data-[swipe-direction=left]:[--toast-exit-x:calc(-100%-1.5rem)] data-[state=closed]:data-[swipe-direction=left]:[--toast-exit-y:0px]",
"data-[state=closed]:data-[swipe-direction=down]:[--toast-exit-y:calc(100%+1.5rem)]",
"data-[state=closed]:data-[swipe-direction=up]:[--toast-exit-y:calc(-100%-1.5rem)]",
// the drag itself is 1:1, not eased
"data-swiping:transition-none",
// toasts past the limit wait invisibly for a slot
"data-limited:pointer-events-none data-limited:opacity-0",
className,
),
},
...children,
);
});
export const ToastTitle = createComponent(function ToastTitle(
{ class: className, ...props }: ToastTitleProps,
...children: Child[]
) {
return ToastTitlePrimitive(
{
...props,
"data-slot": "toast-title",
class: cn("text-sm leading-tight font-medium", className),
},
...children,
);
});
export const ToastDescription = createComponent(function ToastDescription(
{ class: className, ...props }: ToastDescriptionProps,
...children: Child[]
) {
return ToastDescriptionPrimitive(
{
...props,
"data-slot": "toast-description",
class: cn("text-sm leading-snug text-muted-foreground", className),
},
...children,
);
});
export const ToastAction = createComponent(function ToastAction(
{ class: className, ...props }: ToastActionProps,
...children: Child[]
) {
return ToastActionPrimitive(
{
...props,
"data-slot": "toast-action",
class: cn(buttonVariants({ variant: "outline", size: "xs" }), "ml-auto shrink-0", className),
},
...children,
);
});
export const ToastClose = createComponent(function ToastClose(
{ class: className, ...props }: ToastCloseProps,
...children: Child[]
) {
return ToastClosePrimitive(
{
...props,
"data-slot": "toast-close",
class: cn(
buttonVariants({ variant: "ghost", size: "icon-xs" }),
"absolute top-2 right-2 text-muted-foreground hover:text-foreground",
className,
),
},
...children,
XIcon({ class: "size-3.5", "aria-hidden": true }),
Span({ class: "sr-only" }, "Close"),
);
});
/** The `data` shape the ready-made `Toaster` understands. */
export type ToasterToastData = {
action?: { label: string; onClick: () => void };
};
/**
* A ready-made stack: provider, portal, viewport, and a styled toast for every
* entry in the manager. Mount it once and call `manager.add(...)` from anywhere.
*/
export function Toaster({ manager, ...props }: ToastProviderProps & { manager: ToastManager }) {
return ToastProvider(
{ manager, ...props },
ToastPortal(
ToastViewport(
{},
ForEach(
manager.toasts,
(t) => t.id,
(toast) =>
Toast(
{ toast },
Div(
{ class: "flex items-start gap-3 pr-6" },
ToastIcon(toast),
Div(
{ class: "grid flex-1 gap-1" },
ToastTitle(
{},
toast.bind((t) => t.title ?? ""),
),
If(toast.bind((t) => t.description !== undefined)).Then(
ToastDescription(
{},
toast.bind((t) => t.description ?? ""),
),
),
),
If(toast.bind((t) => actionOf(t) !== undefined)).Then(
ToastAction(
{ onClick: () => actionOf(toast.get())?.onClick() },
toast.bind((t) => actionOf(t)?.label ?? ""),
),
),
),
ToastClose({}),
),
),
),
),
);
}
function actionOf(toast: ToastData): ToasterToastData["action"] {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Toast action lives on optional extended toast data.
const data = toast.data as ToasterToastData | undefined;
return data?.action;
}
function ToastIcon(toast: Readable<ToastData>) {
return Span(
{ class: "mt-0.5 shrink-0 empty:hidden", "aria-hidden": true },
If(toast.bind((t) => t.type === "success")).Then(
CircleCheckIcon({ class: "size-4 text-green-600 dark:text-green-400" }),
),
If(toast.bind((t) => t.type === "error")).Then(
CircleAlertIcon({ class: "size-4 text-destructive" }),
),
If(toast.bind((t) => t.type === "info")).Then(
InfoIcon({ class: "size-4 text-blue-600 dark:text-blue-400" }),
),
If(toast.bind((t) => t.type === "loading")).Then(
LoaderCircleIcon({ class: "size-4 animate-spin text-muted-foreground" }),
),
);
}Usage
The whole stack is one component. Toaster mounts the provider, the portal, the viewport, and a styled toast per entry — icon by type, title, description, and an action button when the toast carries one. Mount it once near the root of the app and push toasts from anywhere:
import { createToastManager, Toaster } from "@/lib/components/ui/toast";
export const toast = createToastManager();
// once, near the root
Toaster({ manager: toast });
// anywhere
toast.add({ title: "Saved", description: "Your changes are live.", type: "success" });
The stack
Collapsed, the toasts behind the front one peek out and scale down. Hovering fans them out by their real heights. A toast can be swiped away and keeps travelling in the direction it was thrown, and toasts past the limit wait invisibly for a slot.
All of that is one transform on Toast reading a set of CSS variables the primitive maintains, so the states swap the variables rather than fighting over the property.
Actions
An action button comes from the toast's data:
toast.add({
title: "Message archived",
data: { action: { label: "Undo", onClick: restore } },
});
That shape is ToasterToastData — the ready-made Toaster's own convention, not the primitive's. data is free-form, so change the shape and change Toaster to match.
Building your own
Toaster is a starting point, not a wall. The parts it assembles — ToastProvider, ToastPortal, ToastViewport, Toast, ToastTitle, ToastDescription, ToastAction, ToastClose — are all exported, so a different layout is a rewrite of one function in a file you already own.
API Reference
Every prop the styling does not consume is forwarded to the Toast primitive, so the tables below are the whole surface — the behavior props and the styling ones together.
createToastManager
Creates the ToastManager that owns the toast list and the clocks. toasts is a signal holding the list frontmost-first; add, update, close, remove, promise, pause, and resume change it. Usually created at module scope so any code can push a message.
| Prop | Type | Default | Description |
|---|---|---|---|
timeout | number | 5000 | Auto-dismiss delay in ms for toasts that don't set their own. 0 disables. |
limit | number | 3 | How many toasts show at once. Extra toasts stay in the list with data-limited and their clocks held. |
ToastProvider
Provides the manager and timing to every toast part inside it. Pauses every clock while the pointer is over the stack, while it holds focus, and while the window is blurred or the tab hidden. Makes its own manager when none is passed.
| Prop | Type | Default | Description |
|---|---|---|---|
manager | ToastManager | — | A manager from createToastManager(). Omitted, the provider creates a private one. |
timeout | number | 5000 | Overrides the manager's default auto-dismiss delay. |
limit | number | 3 | Overrides the manager's visible-toast limit. |
gap | number | 16 | Pixels between expanded toasts, used when computing --toast-offset-y. |
hotkey | string | "F6" | The key that moves focus into the viewport. |
ToastViewport
The landmark region holding the stack. Sets role="region" with an aria-label naming the hotkey. Position it yourself; render the toasts inside it with ForEach over manager.toasts. Hover or focus expands the stack and pauses the clocks. Styled as a fixed 360px column in the bottom right corner. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-toast-viewport] | Present |
[data-expanded] | Present while hovered or focused |
Toast
One toast. Sets role="status" with aria-live from the toast's priority, handles swipe-to-dismiss and Escape, and removes itself after the exit transition (data-state="closed") finishes. Focusable. Styled as the stack itself: collapsed toasts peek out behind the front one and scale down, an expanded stack fans out by real heights, and a swiped toast keeps travelling in the direction it was thrown. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
toast* | Readable<ToastData> | — | The toast to render — the readable ForEach hands the render function. |
swipeDirection | SwipeDirection | SwipeDirection[] | ["down", "right"] | Which swipe direction(s) dismiss the toast. |
| Data attribute | Value |
|---|---|
[data-toast-root] | Present |
[data-state] | "open" | "closed" |
[data-type] | The toast's type, when set |
[data-expanded] | Present while the stack is expanded |
[data-behind] | Present when not the frontmost toast |
[data-limited] | Present when past the visible limit |
[data-swiping] | Present while a swipe is in flight |
[data-swipe-direction] | "up" | "down" | "left" | "right" while swiping and through the exit |
| CSS variable | Description |
|---|---|
--toast-index | Position from the front; 0 is the frontmost toast. |
--toast-offset-y | Distance in px to this toast's expanded slot, from measured heights plus the provider's gap. |
--toast-height | This toast's measured height in px. |
--toast-frontmost-height | The frontmost toast's measured height in px, for clamping a collapsed stack. |
--toast-swipe-movement-x | Horizontal pointer travel in px during a swipe. |
--toast-swipe-movement-y | Vertical pointer travel in px during a swipe. |
ToastTitle
The toast's heading. The root points aria-labelledby at it. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-toast-title] | Present |
[data-type] | The toast's type, when set |
ToastDescription
Supporting copy. The root points aria-describedby at it. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-toast-description] | Present |
[data-type] | The toast's type, when set |
ToastAction
A button for the toast's action (undo, retry, …). Runs your onClick, then closes the toast. Styled as an extra-small outline button, pushed to the right. Renders a Button; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-toast-action] | Present |
[data-type] | The toast's type, when set |
ToastClose
Dismisses its toast. Labelled for assistive technology by default. Styled as a ghost icon button in the corner; the X and its screen-reader label are built in. Renders a Button; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-toast-close] | Present |
[data-type] | The toast's type, when set |
ToastPortal
Renders its children into another DOM parent so the stack escapes overflow and stacking contexts. This is the core Portal helper; context still resolves from where the portal is declared.
| Prop | Type | Default | Description |
|---|---|---|---|
to | HTMLElement | Ref<HTMLElement> | document.body | The parent to teleport into. |
disabled | Signal<boolean> | boolean | false | Mounts the children in place instead of teleporting. |
Toaster
The whole stack, ready made: provider, portal, viewport, and a styled toast for every entry in the manager — icon by type, title, description, and an action button when the toast carries one. Mount it once near the root and call manager.add(...) from anywhere.
| Prop | Type | Default | Description |
|---|---|---|---|
manager* | ToastManager | — | The manager from createToastManager() whose toasts it renders. |