Brief notifications called from anywhere. They stack, pause on hover, swipe away, track a promise, and hold a change back until its undo runs out.
pnpm dlx shadcn@latest add @latheui/toastFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-react motionAdd the Lathe items this component imports.
lib/motion.tsCopy the source into your project.
"use client"
import * as React from "react"
import { CircleAlertIcon, CircleCheckIcon, LoaderCircleIcon, XIcon } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { cn } from "@/lib/utils"
import { spring, useMotionSafe } from "@/lib/motion"
import { Kbd, matchesShortcut, parseShortcut, usePlatform } from "@/components/ui/kbd"
type Variant = "default" | "success" | "error" | "loading"
export type ToastOptions = {
description?: React.ReactNode
variant?: Variant
/** Milliseconds before auto-dismiss. Use `Infinity` to keep it until dismissed. */
duration?: number
action?: { label: string; onClick: () => void }
/** Reuse an id to update an existing toast in place. */
id?: string
}
export type UndoToastOptions = Omit<ToastOptions, "action" | "variant"> & {
/** Runs when Undo is pressed. The change never commits. */
onUndo: () => void
/** Runs once the toast leaves without an undo: it timed out, was dismissed, or was pushed out. */
onCommit?: () => void
/** Button text. */
label?: string
/** Shortcut that presses Undo while this is the newest undo toast. `null` turns it off. */
hotkey?: string | null
/** Replaces the toast in place after an undo, for example "Restored". */
undoneMessage?: React.ReactNode
}
type Undo = {
label: string
hotkey: string | null
onUndo: () => void
onCommit?: () => void
undoneMessage?: React.ReactNode
/** Set once undone or committed, so neither runs twice. */
settled: boolean
}
type ToastData = ToastOptions & {
id: string
title: React.ReactNode
variant: Variant
duration: number
undo?: Undo
}
// ---- store ----------------------------------------------------------------
const EMPTY: ToastData[] = []
let toasts: ToastData[] = EMPTY
let counter = 0
const listeners = new Set<() => void>()
function emit(next: ToastData[]) {
toasts = next
listeners.forEach((listener) => listener())
}
function subscribe(listener: () => void) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
/** Commits a pending undo toast that is leaving or being replaced. */
function commit(data: ToastData | undefined) {
if (!data?.undo || data.undo.settled) return
data.undo.settled = true
data.undo.onCommit?.()
}
function show(title: React.ReactNode, options: ToastOptions & { undo?: Undo } = {}) {
const id = options.id ?? `toast-${++counter}`
const variant = options.variant ?? "default"
const data: ToastData = {
...options,
id,
title,
variant,
duration: options.duration ?? (variant === "loading" ? Infinity : 4000),
}
const previous = toasts.find((t) => t.id === id)
if (previous?.undo !== data.undo) commit(previous)
emit(
toasts.some((t) => t.id === id)
? toasts.map((t) => (t.id === id ? data : t))
: [...toasts, data]
)
return id
}
function dismiss(id?: string) {
toasts.filter((t) => !id || t.id === id).forEach(commit)
emit(id ? toasts.filter((t) => t.id !== id) : EMPTY)
}
/** Presses Undo on a toast: runs onUndo, then removes it or shows the undone message. */
function undo(id: string) {
const data = toasts.find((t) => t.id === id)
if (!data?.undo || data.undo.settled) return
data.undo.settled = true
data.undo.onUndo()
if (data.undo.undoneMessage) show(data.undo.undoneMessage, { id, duration: 2400 })
else emit(toasts.filter((t) => t.id !== id))
}
type PromiseMessages<T> = {
loading: React.ReactNode
success: React.ReactNode | ((value: T) => React.ReactNode)
error: React.ReactNode | ((error: unknown) => React.ReactNode)
}
/** Show a toast from anywhere. Requires one `<Toaster />` in the tree. */
const toast = Object.assign(show, {
success: (title: React.ReactNode, options?: ToastOptions) =>
show(title, { ...options, variant: "success" }),
error: (title: React.ReactNode, options?: ToastOptions) =>
show(title, { ...options, variant: "error" }),
loading: (title: React.ReactNode, options?: ToastOptions) =>
show(title, { ...options, variant: "loading" }),
/**
* A change that can be taken back. The toast counts down, and the change commits only if nobody
* presses Undo (or the hotkey) in time. Do the work optimistically, and finish it in onCommit.
*/
undo: (title: React.ReactNode, { onUndo, onCommit, label = "Undo", hotkey = "mod+z", undoneMessage, ...options }: UndoToastOptions) =>
show(title, {
duration: 6000,
...options,
variant: "default",
undo: { label, hotkey, onUndo, onCommit, undoneMessage, settled: false },
}),
/** One toast that follows a promise from loading to success or error. */
promise<T>(promise: Promise<T>, messages: PromiseMessages<T>) {
const id = show(messages.loading, { variant: "loading" })
promise.then(
(value) => {
const title =
typeof messages.success === "function" ? messages.success(value) : messages.success
show(title, { id, variant: "success" })
},
(error) => {
const title =
typeof messages.error === "function" ? messages.error(error) : messages.error
show(title, { id, variant: "error" })
}
)
return id
},
dismiss,
})
// ---- view -----------------------------------------------------------------
const positions = {
"top-right": "top-0 right-0 items-end",
"top-center": "top-0 left-1/2 -translate-x-1/2 items-center",
"bottom-right": "bottom-0 right-0 items-end",
"bottom-center": "bottom-0 left-1/2 -translate-x-1/2 items-center",
} as const
export type ToasterProps = {
position?: keyof typeof positions
/** Most toasts shown at once. Older ones are dismissed. */
limit?: number
/** Accessible name of the notification region. */
label?: string
className?: string
}
function Toaster({
position = "bottom-right",
limit = 4,
label = "Notifications",
className,
}: ToasterProps) {
const all = React.useSyncExternalStore(subscribe, () => toasts, () => EMPTY)
const [paused, setPaused] = React.useState(false)
const visible = all.slice(-limit)
const top = position.startsWith("top")
// Past the limit, the oldest toasts are dismissed, which also commits any pending undo.
React.useEffect(() => {
all.slice(0, Math.max(0, all.length - limit)).forEach((t) => dismiss(t.id))
}, [all, limit])
// The newest undo toast answers its hotkey, except in a text field, which has its own undo.
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.repeat) return
const target = event.target as HTMLElement | null
if (target?.closest?.("input, textarea, select, [contenteditable='true'], [contenteditable='']")) return
const newest = all
.slice(-limit)
.reverse()
.find((t) => t.undo && !t.undo.settled)
if (!newest?.undo?.hotkey || !matchesShortcut(event, newest.undo.hotkey)) return
event.preventDefault()
undo(newest.id)
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [all, limit])
// Nothing left to hover or focus, so nothing can clear `paused`.
if (visible.length === 0 && paused) setPaused(false)
return (
<section
data-slot="toaster"
aria-label={label}
className={cn("pointer-events-none fixed z-100 w-full max-w-[420px] p-4", positions[position], className)}
>
<ol
className={cn("flex gap-2", top ? "flex-col-reverse" : "flex-col")}
onPointerEnter={() => setPaused(true)}
onPointerLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setPaused(false)
}}
>
<AnimatePresence initial={false} mode="popLayout">
{visible.map((data) => (
<ToastItem key={data.id} data={data} paused={paused} fromTop={top} />
))}
</AnimatePresence>
</ol>
</section>
)
}
const icons = {
default: null,
success: <CircleCheckIcon aria-hidden="true" className="size-4 shrink-0" />,
error: <CircleAlertIcon aria-hidden="true" className="size-4 shrink-0 text-destructive" />,
loading: (
<LoaderCircleIcon aria-hidden="true" className="size-4 shrink-0 animate-spin text-muted-foreground" />
),
}
const RING = 2 * Math.PI * 5.5
/** A ring that drains over the toast's time and stops while the stack is paused. */
function Countdown({ duration, paused }: { duration: number; paused: boolean }) {
const ring = React.useRef<SVGCircleElement>(null)
const animation = React.useRef<Animation | null>(null)
React.useEffect(() => {
if (!ring.current || !Number.isFinite(duration)) return
const drain = ring.current.animate([{ strokeDashoffset: 0 }, { strokeDashoffset: RING }], {
duration,
easing: "linear",
fill: "forwards",
})
animation.current = drain
return () => drain.cancel()
}, [duration])
React.useEffect(() => {
if (paused) animation.current?.pause()
else animation.current?.play()
}, [paused, duration])
return (
<svg aria-hidden="true" viewBox="0 0 14 14" className="size-3.5 shrink-0 -rotate-90">
<circle cx="7" cy="7" r="5.5" fill="none" strokeWidth="1.75" className="stroke-current opacity-20" />
<circle
ref={ring}
cx="7"
cy="7"
r="5.5"
fill="none"
strokeWidth="1.75"
strokeLinecap="round"
strokeDasharray={RING}
className="stroke-current"
/>
</svg>
)
}
const ariaKeys: Record<string, string> = { meta: "Meta", control: "Control", alt: "Alt", shift: "Shift" }
function UndoButton({ data, paused }: { data: ToastData & { undo: Undo }; paused: boolean }) {
const platform = usePlatform()
const { hotkey, label } = data.undo
// aria-keyshortcuts wants "Meta+Z" rather than symbols.
const shortcut = hotkey
? parseShortcut(hotkey, platform)[0]
?.map((key) => ariaKeys[key.id] ?? (key.id.length === 1 ? key.id.toUpperCase() : key.other))
.join("+")
: undefined
return (
<button
type="button"
aria-keyshortcuts={shortcut}
onClick={() => undo(data.id)}
className="flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-border pr-2 pl-1.5 text-xs font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
<Countdown duration={data.duration} paused={paused} />
{label}
{hotkey && <Kbd keys={hotkey} variant="ghost" aria-hidden="true" className="hidden pl-0.5 pointer-fine:inline-flex" />}
</button>
)
}
function ToastItem({
data,
paused,
fromTop,
ref,
}: {
data: ToastData
paused: boolean
fromTop: boolean
ref?: React.Ref<HTMLLIElement>
}) {
const { id, duration, variant } = data
const { reduced, safe } = useMotionSafe()
const remaining = React.useRef(duration)
// A changed duration or variant (for example loading -> success) restarts the clock.
React.useEffect(() => {
remaining.current = duration
}, [duration, variant])
React.useEffect(() => {
if (paused || !Number.isFinite(duration)) return
const started = Date.now()
const timer = setTimeout(() => dismiss(id), remaining.current)
return () => {
clearTimeout(timer)
remaining.current = Math.max(0, remaining.current - (Date.now() - started))
}
}, [paused, duration, variant, id])
const offset = reduced ? 0 : fromTop ? -16 : 16
return (
<motion.li
ref={ref}
layout={!reduced}
data-slot="toast"
data-variant={variant}
role={variant === "error" ? "alert" : "status"}
initial={{ opacity: 0, y: offset, scale: reduced ? 1 : 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: reduced ? 1 : 0.97, transition: { duration: 0.15 } }}
transition={safe(spring.smooth)}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={{ left: 0.05, right: 0.9 }}
dragSnapToOrigin
onDragEnd={(_, info) => {
if (info.offset.x > 90 || info.velocity.x > 600) dismiss(id)
}}
className="pointer-events-auto flex w-full touch-pan-y items-start gap-3 rounded-lg border border-border bg-popover p-3.5 pr-2.5 text-popover-foreground shadow-lg shadow-black/5 select-none"
>
{/* The icon and title share one 28px line with the buttons, so all of them center on it. */}
{icons[variant] && <span className="flex h-7 shrink-0 items-center">{icons[variant]}</span>}
<div className="flex min-w-0 flex-1 flex-col">
<p className="flex min-h-7 items-center text-sm leading-5 font-medium">{data.title}</p>
{data.description && (
<p className="-mt-0.5 text-[13px] leading-5 text-muted-foreground">{data.description}</p>
)}
</div>
{data.undo && !data.undo.settled && <UndoButton data={data as ToastData & { undo: Undo }} paused={paused} />}
{data.action && (
<button
type="button"
onClick={() => {
data.action?.onClick()
dismiss(id)
}}
className="h-7 shrink-0 cursor-pointer rounded-md border border-border px-2.5 text-xs font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
{data.action.label}
</button>
)}
<button
type="button"
aria-label="Dismiss notification"
onClick={() => dismiss(id)}
className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<XIcon aria-hidden="true" className="size-3.5" />
</button>
</motion.li>
)
}
export { Toaster, toast }Depends on
Returns the toast id. toast.success, toast.error and toast.loading set the variant. toast.dismiss(id?) removes one toast, or all of them.
| Prop | Type | Default | Description |
|---|---|---|---|
| description | ReactNode | — | Secondary line under the title. |
| variant | "default" | "success" | "error" | "loading" | "default" | Sets the icon and the live-region role. |
| duration | number | 4000 | Milliseconds before auto-dismiss. Loading toasts default to Infinity. |
| action | { label: string; onClick: () => void } | — | A button in the toast. Clicking it also dismisses the toast. |
| id | string | — | Reuse an id to update an existing toast instead of adding one. |
A change that can be taken back. Make the change in your UI right away, then finish it in onCommit, which runs only if Undo is not pressed in time. Accepts description, duration (default 6000) and id too.
| Prop | Type | Default | Description |
|---|---|---|---|
| onUndo* (required) | () => void | — | Runs when Undo or the hotkey is pressed. Put things back here. |
| onCommit | () => void | — | Runs once when the toast leaves without an undo: it timed out, was dismissed or swiped away, or was pushed out by newer toasts. |
| label | string | "Undo" | Button text. |
| hotkey | string | null | "mod+z" | Presses Undo on the newest undo toast. Ignored while typing in a field, which has its own undo. null turns it off. |
| undoneMessage | ReactNode | — | Replaces the toast in place after an undo, for example "Restored". Without it the toast just leaves. |
| Prop | Type | Default | Description |
|---|---|---|---|
| loading* (required) | ReactNode | — | Title while the promise is pending. |
| success* (required) | ReactNode | (value) => ReactNode | — | Title when it resolves. |
| error* (required) | ReactNode | (error) => ReactNode | — | Title when it rejects. |
| Prop | Type | Default | Description |
|---|---|---|---|
| position | "top-right" | "top-center" | "bottom-right" | "bottom-center" | "bottom-right" | Screen corner or edge. |
| limit | number | 4 | Most toasts shown at once. Older ones are dismissed, which commits a pending undo. |
| label | string | "Notifications" | Accessible name of the region. |
| className | string | — | Classes for the fixed region. |
<Toaster position="top-center" limit={3} />function remove(item: Item) {
setItems((items) => items.filter((i) => i.id !== item.id))
toast.undo("Message deleted", {
onUndo: () => setItems((items) => [...items, item]),
onCommit: () => api.delete(item.id),
})
}const id = toast.loading("Exporting…")
// later
toast.success("Export ready", { id, action: { label: "Download", onClick: download } })