Short notifications that appear over the page, built on Base UI Toast, with types, actions and loading states.
Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/toast.jsonInstalls to @/components/ui/toast.
Manual installation
Install the dependencies:
pnpm add @base-ui/react@^1.8.0 lucide-react@^1.48.0Add the components it builds on: button.
Copy the source into your project:
'use client'
import {
type ToastManager,
type ToastManagerPromiseOptions,
type ToastManagerUpdateOptions,
Toast as ToastPrimitive,
type UseToastManagerReturnValue,
} from '@base-ui/react/toast'
import { CircleCheckIcon, CircleXIcon, InfoIcon, Loader2Icon, TriangleAlertIcon, XIcon } from 'lucide-react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
// The toast types that get a coloured edge, an icon and a type label. `error` and `destructive` look the same;
// `toast.promise()` uses `loading`, `success` and `error`.
type ToastType = 'info' | 'success' | 'warning' | 'error' | 'destructive' | 'loading'
// A toast with an action button stays until the user closes it or takes the action, unless it sets its own `timeout`.
// Toasts that time out also pause while the pointer or focus is inside the toast area (Base UI's viewport).
function withToastDefaults<T extends { actionProps?: unknown; timeout?: number }>(options: T): T {
if (options.actionProps && options.timeout === undefined) {
return { ...options, timeout: 0 }
}
return options
}
function withPromiseDefaults<Value, Data extends object>(
options: ToastManagerPromiseOptions<Value, Data>,
): ToastManagerPromiseOptions<Value, Data> {
const resolve = <A,>(
option: string | ToastManagerUpdateOptions<Data> | ((arg: A) => string | ToastManagerUpdateOptions<Data>),
) => {
if (typeof option === 'function') {
return (arg: A) => {
const resolved = option(arg)
return typeof resolved === 'string' ? resolved : withToastDefaults(resolved)
}
}
return typeof option === 'string' ? option : withToastDefaults(option)
}
return {
loading: options.loading,
success: resolve(options.success),
error: resolve(options.error),
}
}
function withManagerDefaults<M extends Pick<ToastManager, 'add' | 'update' | 'promise'>>(manager: M): M {
return {
...manager,
add: (options) => manager.add(withToastDefaults(options)),
update: (id, updates) =>
manager.update(
id,
typeof updates === 'function' ? (prev) => withToastDefaults(updates(prev)) : withToastDefaults(updates),
),
promise: (promise, options) => manager.promise(promise, withPromiseDefaults(options)),
}
}
// Creates a toast manager to pass to `<Toaster toastManager={…} />`. Use it to call toasts from outside React, or to give
// one part of the page its own toasts.
function createToastManager<Data extends object = object>(): ToastManager<Data> {
return withManagerDefaults(ToastPrimitive.createToastManager<Data>())
}
// The app-wide toast manager used by `<Toaster />`: `toast.add({ title, description, type })`, `toast.promise()`,
// `toast.update()` and `toast.close()`.
const toast = createToastManager()
// The toasts and toast methods of the nearest Toaster/ToastProvider, for use inside React components.
function useToastManager<Data extends object = object>(): UseToastManagerReturnValue<Data> {
const manager = ToastPrimitive.useToastManager<Data>()
return React.useMemo(() => withManagerDefaults(manager), [manager])
}
function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
return <ToastPrimitive.Provider {...props} />
}
function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
return <ToastPrimitive.Portal data-slot="toast-portal" {...props} />
}
// A labelled region (default "Notifications") that announces new toasts politely, or urgently for `priority: 'high'`.
// F6 moves focus into it.
function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
return (
<ToastPrimitive.Viewport
data-slot="toast-viewport"
className={cn(
'pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full',
className,
)}
{...props}
/>
)
}
function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
return (
<ToastPrimitive.Root
data-slot="toast"
className={cn(
'group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-lg border bg-popover text-sm text-foreground shadow-lg will-change-transform outline-none select-none focus-visible:ring-3 focus-visible:ring-ring',
// Typed toasts: a tinted background, a 3px coloured left edge and a coloured icon. Text stays `text-foreground`.
// Info uses `primary`, success and warning their own tokens, and error `destructive` mixed 30% towards
// `foreground` so the edge and icon keep at least 3:1 against the toast in both themes and modes.
'data-[type=info]:[--toast-accent:var(--primary)]',
'data-[type=success]:[--toast-accent:var(--success)]',
'data-[type=warning]:[--toast-accent:var(--warning)]',
'data-[type=error]:[--toast-accent:color-mix(in_srgb,var(--destructive)_70%,var(--foreground))]',
'data-[type=destructive]:[--toast-accent:color-mix(in_srgb,var(--destructive)_70%,var(--foreground))]',
'data-[type=loading]:[--toast-accent:var(--muted-foreground)]',
'data-type:border-l-3 data-type:border-l-[color:var(--toast-accent,var(--border))] data-type:bg-[color-mix(in_srgb,var(--toast-accent,var(--popover))_8%,var(--popover))]',
// Stacking, expanding and swipe animations from shadcn
'[--gap:0.75rem] [--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))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]',
'h-(--height) [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))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms] motion-reduce:[transition:none]',
"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]: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))]',
'data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]',
'data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]',
'data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]',
'data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]',
'data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]',
className,
)}
{...props}
/>
)
}
function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
return (
<ToastPrimitive.Content
data-slot="toast-content"
className={cn(
'flex h-full items-start gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100 motion-reduce:transition-none',
className,
)}
{...props}
/>
)
}
function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
return <ToastPrimitive.Title data-slot="toast-title" className={cn('text-sm font-medium', className)} {...props} />
}
function ToastDescription({ className, ...props }: ToastPrimitive.Description.Props) {
return (
<ToastPrimitive.Description
data-slot="toast-description"
className={cn('text-sm text-foreground', className)}
{...props}
/>
)
}
function ToastAction({
className,
render = <Button variant="outline" size="sm" />,
...props
}: ToastPrimitive.Action.Props) {
return (
<ToastPrimitive.Action
data-slot="toast-action"
render={render}
className={cn('-mt-1.5 shrink-0', className)}
{...props}
/>
)
}
// Icon-only close button (32px). Pass `aria-label` to translate it; Toaster sets it from `closeLabel`.
function ToastClose({
className,
children,
render = <Button variant="ghost" size="icon-sm" />,
...props
}: ToastPrimitive.Close.Props) {
return (
<ToastPrimitive.Close
data-slot="toast-close"
aria-label="Close notification"
render={render}
className={cn('-mt-1.5 shrink-0 text-muted-foreground hover:text-foreground', className)}
{...props}
>
{children ?? <XIcon aria-hidden="true" />}
</ToastPrimitive.Close>
)
}
const toastIcons: Partial<Record<string, React.ComponentType<React.SVGProps<SVGSVGElement>>>> = {
info: InfoIcon,
success: CircleCheckIcon,
warning: TriangleAlertIcon,
error: CircleXIcon,
destructive: CircleXIcon,
loading: Loader2Icon,
}
// Decorative: the type is announced by the visually hidden type label in the title, so the icon is hidden from
// assistive technology.
function ToastIcon({ type, className, ...props }: React.ComponentProps<'span'> & { type: string | undefined }) {
const Icon = type ? toastIcons[type] : undefined
if (!Icon) {
return null
}
return (
<span
data-slot="toast-icon"
aria-hidden="true"
className={cn(
"shrink-0 text-(--toast-accent) [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-5",
className,
)}
{...props}
>
<Icon className={cn(type === 'loading' && 'animate-spin motion-reduce:animate-none')} />
</span>
)
}
// The built-in types, which editors suggest, plus any custom `type` an app gives its toasts
type ToastTypeLabels = Partial<Record<ToastType, string>> & Record<string, string | undefined>
const defaultTypeLabels: ToastTypeLabels = {
info: 'Information',
success: 'Success',
warning: 'Warning',
error: 'Error',
destructive: 'Error',
}
function ToastList({ closeLabel, typeLabels }: { closeLabel: string; typeLabels: ToastTypeLabels }) {
const { toasts } = ToastPrimitive.useToastManager()
return toasts.map((toastItem) => {
const typeLabel = toastItem.type ? typeLabels[toastItem.type] : undefined
const srType = typeLabel ? <span className="sr-only">{`${typeLabel}: `}</span> : null
const hasTitle = toastItem.title != null && toastItem.title !== ''
return (
<Toast key={toastItem.id} toast={toastItem}>
<ToastContent>
<ToastIcon type={toastItem.type} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
{hasTitle ? (
<ToastTitle>
{srType}
{toastItem.title}
</ToastTitle>
) : null}
{!hasTitle && toastItem.description ? (
<ToastDescription>
{srType}
{toastItem.description}
</ToastDescription>
) : (
<ToastDescription />
)}
</div>
<ToastAction />
<ToastClose aria-label={closeLabel} />
</ToastContent>
</Toast>
)
})
}
type ToasterProps = ToastPrimitive.Provider.Props & {
/** Accessible name of the notification region. */
label?: string
/** Accessible name of each toast's close button. */
closeLabel?: string
/** Visually hidden prefix per toast type, read before the title, e.g. "Error: ". Merged with the English defaults. */
typeLabels?: ToastTypeLabels
/** Class names for the viewport, e.g. to move it to the top of the screen. */
className?: string
}
// Renders the toasts of `toastManager` (the app-wide `toast` by default). Put one in the root layout, or wrap part of the
// app in it and call `useToastManager()` inside.
function Toaster({
children,
toastManager = toast,
label = 'Notifications',
closeLabel = 'Close notification',
typeLabels,
className,
...props
}: ToasterProps) {
const labels = React.useMemo(() => ({ ...defaultTypeLabels, ...typeLabels }), [typeLabels])
return (
<ToastProvider toastManager={toastManager} {...props}>
{children}
<ToastPortal>
<ToastViewport aria-label={label} className={className}>
<ToastList closeLabel={closeLabel} typeLabels={labels} />
</ToastViewport>
</ToastPortal>
</ToastProvider>
)
}
export {
Toaster,
Toast,
ToastAction,
ToastClose,
ToastContent,
ToastDescription,
ToastIcon,
ToastPortal,
ToastProvider,
ToastTitle,
ToastViewport,
createToastManager,
toast,
useToastManager,
type ToasterProps,
type ToastType,
type ToastTypeLabels,
}Short, temporary notifications that confirm what just happened, like a sent application or an archived case. Built on Base UI Toast, with shadcn's stacking and swipe animations and the RDF apps' look for typed toasts: a tinted background, a 3px coloured left edge and an icon.
Features
<Toaster />renders the notification region; calltoast.add()from anywhere, oruseToastManager()inside a Toaster- Types
info,success,warninganderror(ordestructive), each with its own icon, edge colour and a visually hidden type label, so the type is never shown by colour alone toast.promise()shows a loading toast that turns into a success or error toast- An action button through
actionProps; toasts with an action stay until they are closed - Pauses while hovered or focused, can be swiped away or closed with the close button
- Toasts stack and expand on hover or focus;
limitsets how many are shown at once - Every label (
label,closeLabel,typeLabels) can be translated - Colours from theme tokens only, in both themes and in light and dark mode
Usage
Render one <Toaster /> in the root layout and call toast from anywhere, also outside React components.
// app/layout.tsx
import { Toaster } from '@/components/ui/toast'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Toaster />
</body>
</html>
)
}import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
export function SendApplication() {
return (
<Button
onClick={() =>
toast.add({
type: 'success',
title: 'Application sent',
description: 'Your case number is 2026-04812.',
})
}
>
Send application
</Button>
)
}Inside a Toaster, useToastManager() returns the same methods for that Toaster only. The examples below use it, so each
preview on this page has its own toasts.
Examples
Default
A plain toast with a title and a description.
Types
info uses primary, success and warning their theme tokens, and error destructive (mixed 30% towards
foreground so the edge and icon keep a 3:1 contrast in every theme). Text stays foreground.
Screen readers hear the type first, for example "Error: The application could not be sent". Use priority: 'high' for
errors that need attention now; they are announced immediately instead of politely.
With Action
actionProps adds a button; its props go to the button. A toast with an action does not close by itself unless you
pass a timeout, so the user has time to reach the button.
Promise
promise(promise, { loading, success, error }) shows a loading toast with a spinner, then updates it with the result.
Each option is a string (the description), an object with toast options, or a function of the result or error.
Stacked
Several toasts stack on top of each other and expand when the stack is hovered or focused. Toasts over limit are hidden
until there is room.
Props
Toaster
| Prop | Type | Default | Description |
|---|---|---|---|
toastManager | ToastManager | toast | The manager whose toasts are shown; create one with createToastManager() |
timeout | number | 5000 | Default time in ms before a toast closes; 0 keeps it open |
limit | number | 3 | Maximum number of toasts shown at once |
label | string | 'Notifications' | Accessible name of the notification region |
closeLabel | string | 'Close notification' | Accessible name of each close button |
typeLabels | Partial<Record<ToastType, string>> | English labels | Visually hidden prefix per type, e.g. { error: 'Fel' } |
className | string | – | Classes for the viewport, e.g. to position it |
children | ReactNode | – | Content that can call useToastManager() |
toast.add(options)
| Option | Type | Default | Description |
|---|---|---|---|
title | ReactNode | – | Main message |
description | ReactNode | – | Supporting text |
type | 'info' | 'success' | 'warning' | 'error' | 'destructive' | 'loading' | – | Icon, edge colour and type label |
timeout | number | Toaster timeout, or 0 with an action | Time in ms before it closes |
priority | 'low' | 'high' | 'low' | high is announced immediately |
actionProps | button props | – | Adds an action button, e.g. { children: 'Undo', onClick } |
onClose | () => void | – | Called when the toast closes |
id | string | generated | Adding a toast with an existing id updates it |
add() returns the id. update(id, options) changes a toast, close(id) closes one (or all without an id) and
promise(promise, options) shows a loading toast for a promise.
Subcomponents
Toaster- Provider, portal and viewport with the default toast layouttoast/createToastManager()- The app-wide manager, or a new one for a separate ToasteruseToastManager()- The toasts and methods of the nearest ToasterToastProvider,ToastPortal,ToastViewport,Toast,ToastContent,ToastTitle,ToastDescription,ToastAction,ToastClose,ToastIcon- The parts, for building a custom toast layout
Accessibility
- The viewport is a region named "Notifications" with
aria-live="polite", so new toasts are read without moving focus;priority: 'high'toasts are announced as alerts - Each toast is a non-modal dialog labelled by its title; the type ("Success", "Warning", …) is part of the title for screen readers and shown with an icon, never with colour alone
- The edge and icon colours keep at least 3:1 contrast and the text uses
foregroundon a light tint - Timers pause while the pointer or keyboard focus is in the toast area, and while the window is not focused
- F6 moves focus to the toasts; Tab reaches the action and close buttons, and focus returns afterwards
- Toasts with an action do not close by themselves by default, since a keyboard or screen reader user may need longer to reach the button (WCAG 2.2.1). Don't put the only way to do something in a toast; offer it on the page as well
- The close button is 32px with an accessible name you can translate with
closeLabel - Animations are turned off when the user prefers reduced motion
Related Components
- Alert - For messages that stay on the page
- Alert Dialog - For messages the user must respond to
- Button - Used for the action and close buttons