Toast

A succinct message that is displayed temporarily.

import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"

export default function ToastDemo() {
  function showToast() {
    const id = toast.add({
      title: "Event created",
      description: "Sunday, December 3 at 9:00 AM",
      actionProps: {
        children: "Undo",
        onClick() {
          toast.close(id)
        },
      },
    })
  }

  return (
    <Button variant="outline" onClick={showToast}>
      Show toast
    </Button>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx lucide-react

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts and state-styles.ts from the same page. states.ts holds the opaque popup surface a toast is built on, and state-styles.ts is how a toast and its content read the stack's state. The toast also needs the Button, which it uses for the action and the close control, the Spinner for a loading toast's glyph, and the Stack for its text column.

Copy and paste the following code into your project.

components/ui/toast.tsx
"use client"

import type { CSSProperties, ReactNode } from "react"
import { Toast as ToastPrimitive } from "@base-ui/react/toast"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"
import { CircleCheck, Info, OctagonX, TriangleAlert, X } from "lucide-react"

import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { VStack } from "@/components/ui/stack"
import { stateStyles } from "@/lib/state-styles"
import { breakpoints } from "@/theme/breakpoints.stylex"
import { states } from "@/theme/states"
import {
  color,
  duration,
  ease,
  layer,
  leading,
  radius,
  shadow,
  space,
  text,
} from "@/theme/tokens.stylex"

const toast = ToastPrimitive.createToastManager()

const styles = stylex.create({
  viewport: {
    position: "fixed",
    zIndex: layer.popup,
    bottom: space.s4,
    right: space.s4,
    left: { default: space.s4, [breakpoints.sm]: "auto" },
    width: { default: "auto", [breakpoints.sm]: "100%" },
    marginInline: { default: "auto", [breakpoints.sm]: 0 },
    maxWidth: "24rem",
    pointerEvents: "none",
    outlineStyle: "none",
  },
  stackOrder: {
    zIndex: "calc(1000 - var(--toast-index))",
  },
  root: {
    position: "absolute",
    right: 0,
    bottom: 0,
    width: "100%",
    height: "var(--carbon-toast-height)",
    transformOrigin: "bottom",
    borderRadius: radius.xl,
    boxShadow: {
      default: shadow.popupLifted,
      ":focus-visible": `0 0 0 1px ${color.ring}, 0 0 0 3px color-mix(in oklab, ${color.ring} 30%, transparent), ${shadow.popupLifted}`,
    },
    userSelect: "none",
    pointerEvents: "auto",
    willChange: "transform",
    "--carbon-toast-gap": space.s3,
    "--carbon-toast-peek": space.s3,
    "--carbon-toast-height": "var(--toast-frontmost-height, var(--toast-height))",
    "--carbon-toast-scale": "max(0, 1 - var(--toast-index) * 0.1)",
    "--carbon-toast-shrink": "calc(1 - var(--carbon-toast-scale))",
    "--carbon-toast-offset-y":
      "calc(-1 * var(--toast-offset-y) - var(--toast-index) * var(--carbon-toast-gap) + var(--toast-swipe-movement-y))",
    transform:
      "translateX(var(--toast-swipe-movement-x)) translateY(calc(var(--toast-swipe-movement-y) - var(--toast-index) * var(--carbon-toast-peek) - var(--carbon-toast-shrink) * var(--carbon-toast-height))) scale(var(--carbon-toast-scale))",
    transitionProperty: "transform, opacity, height",
    transitionDuration: {
      default: `${duration.stack}, ${duration.stack}, 150ms`,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: `${ease.decelerate}, ${ease.standard}, ${ease.standard}`,
    "::after": {
      content: '""',
      position: "absolute",
      top: "100%",
      left: 0,
      width: "100%",
      height: "calc(var(--carbon-toast-gap) + 1px)",
    },
  },
  expanded: {
    height: "var(--toast-height)",
    transform: "translateX(var(--toast-swipe-movement-x)) translateY(var(--carbon-toast-offset-y))",
  },
  limited: { opacity: 0 },
  starting: { transform: "translateY(150%)" },
  content: {
    display: "flex",
    height: "100%",
    alignItems: "center",
    gap: space.s3,
    overflow: "hidden",
    padding: space.s4,
    transitionProperty: "opacity",
    transitionDuration: {
      default: duration.stackFade,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.decelerate,
  },
  behind: { opacity: 0 },
  text: { minWidth: 0 },
  title: { fontSize: text.sm, lineHeight: leading.sm, fontWeight: 500 },
  description: { fontSize: text.sm, lineHeight: leading.sm, color: color.mutedForeground },
  action: { flexShrink: 0 },
  close: {
    flexShrink: 0,
    color: { default: color.mutedForeground, ":hover": color.foreground },
  },
  icon: { display: "flex", flexShrink: 0, "--carbon-icon-size": space.s4 },
  iconError: { color: color.destructive },
})

const exits = stylex.create({
  none: { transform: "translateY(150%)" },
  down: { transform: "translateY(calc(var(--toast-swipe-movement-y) + 150%))" },
  up: { transform: "translateY(calc(var(--toast-swipe-movement-y) - 150%))" },
  left: {
    transform:
      "translateX(calc(var(--toast-swipe-movement-x) - 150%)) translateY(var(--carbon-toast-offset-y))",
  },
  right: {
    transform:
      "translateX(calc(var(--toast-swipe-movement-x) + 150%)) translateY(var(--carbon-toast-offset-y))",
  },
})

type Overrides = { xstyle?: StyleXStyles; className?: string }

type StateOverrides = Overrides & { style?: CSSProperties }

function ToastProvider(props: ToastPrimitive.Provider.Props) {
  return <ToastPrimitive.Provider {...props} />
}

function ToastPortal(props: ToastPrimitive.Portal.Props) {
  return <ToastPrimitive.Portal data-slot="toast-portal" {...props} />
}

type ToastViewportProps = Omit<ToastPrimitive.Viewport.Props, "className"> & Overrides

function ToastViewport({ xstyle, className, ...props }: ToastViewportProps) {
  const sx = stylex.props(styles.viewport, xstyle)
  return (
    <ToastPrimitive.Viewport
      data-slot="toast-viewport"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type ToastProps = Omit<ToastPrimitive.Root.Props, "className" | "style"> & StateOverrides

function Toast({ xstyle, style, className, ...props }: ToastProps) {
  return (
    <ToastPrimitive.Root
      data-slot="toast"
      {...props}
      {...stateStyles(
        (state) => [
          states.popupSurface,
          styles.root,
          styles.stackOrder,
          state.expanded && styles.expanded,
          state.transitionStatus === "starting" && styles.starting,
          state.transitionStatus === "ending" &&
            !state.limited &&
            exits[state.swipeDirection ?? "none"],
          state.limited && styles.limited,
          xstyle,
        ],
        { className, style },
      )}
    />
  )
}

type ToastContentProps = Omit<ToastPrimitive.Content.Props, "className" | "style"> & StateOverrides

function ToastContent({ xstyle, style, className, ...props }: ToastContentProps) {
  return (
    <ToastPrimitive.Content
      data-slot="toast-content"
      {...props}
      {...stateStyles(
        (state) => [styles.content, state.behind && !state.expanded && styles.behind, xstyle],
        { className, style },
      )}
    />
  )
}

type ToastTitleProps = Omit<ToastPrimitive.Title.Props, "className"> & Overrides

function ToastTitle({ xstyle, className, ...props }: ToastTitleProps) {
  const sx = stylex.props(styles.title, xstyle)
  return (
    <ToastPrimitive.Title
      data-slot="toast-title"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type ToastDescriptionProps = Omit<ToastPrimitive.Description.Props, "className"> & Overrides

function ToastDescription({ xstyle, className, ...props }: ToastDescriptionProps) {
  const sx = stylex.props(styles.description, xstyle)
  return (
    <ToastPrimitive.Description
      data-slot="toast-description"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

function ToastAction({
  render = <Button variant="secondary" size="sm" xstyle={styles.action} />,
  ...props
}: ToastPrimitive.Action.Props) {
  return <ToastPrimitive.Action data-slot="toast-action" render={render} {...props} />
}

function ToastClose({
  render = <Button variant="ghost" size="icon-sm" xstyle={styles.close} />,
  children,
  ...props
}: ToastPrimitive.Close.Props) {
  return (
    <ToastPrimitive.Close data-slot="toast-close" aria-label="Close" render={render} {...props}>
      {children ?? <X aria-hidden="true" />}
    </ToastPrimitive.Close>
  )
}

const icons: Record<string, ReactNode> = {
  success: <CircleCheck aria-hidden="true" />,
  info: <Info aria-hidden="true" />,
  warning: <TriangleAlert aria-hidden="true" />,
  error: <OctagonX aria-hidden="true" {...stylex.props(styles.iconError)} />,
  loading: <Spinner aria-hidden="true" />,
}

function ToastIcon({ type }: { type: string | undefined }) {
  const icon = type ? icons[type] : null
  if (!icon) return null

  const sx = stylex.props(styles.icon)
  return (
    <span data-slot="toast-icon" {...sx} className={clsx("carbon-icons", sx.className)}>
      {icon}
    </span>
  )
}

function ToastList() {
  const { toasts } = ToastPrimitive.useToastManager()

  return toasts.map((item) => (
    <Toast key={item.id} toast={item}>
      <ToastContent>
        <ToastIcon type={item.type} />
        <VStack gap={1} grow xstyle={styles.text}>
          <ToastTitle />
          <ToastDescription />
        </VStack>
        <ToastAction />
        <ToastClose />
      </ToastContent>
    </Toast>
  ))
}

function Toaster({ children, toastManager = toast, ...props }: ToastPrimitive.Provider.Props) {
  return (
    <ToastProvider toastManager={toastManager} {...props}>
      {children}
      <ToastPortal>
        <ToastViewport>
          <ToastList />
        </ToastViewport>
      </ToastPortal>
    </ToastProvider>
  )
}

const createToastManager = ToastPrimitive.createToastManager
const useToastManager = ToastPrimitive.useToastManager

export {
  Toast,
  ToastAction,
  ToastClose,
  ToastContent,
  ToastDescription,
  ToastIcon,
  ToastPortal,
  ToastProvider,
  ToastTitle,
  ToastViewport,
  Toaster,
  createToastManager,
  toast,
  useToastManager,
}

Update the import paths to match your project setup.

Render the Toaster once, before your app. A toast fired while the app mounts is dropped if the Toaster has not subscribed yet.

main.tsx
import "./styles/theme.css"
import "./styles/interop.css"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { Toaster } from "@/components/ui/toast"
import App from "./App"

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <Toaster />
    <App />
  </StrictMode>,
)

Usage

import { toast } from "@/components/ui/toast"
toast.add({
  title: "Event created",
  description: "Sunday, December 3 at 9:00 AM",
})

toast is a manager created outside React, so it works from an event handler, a store or a fetch callback as well as from a component. toast.add returns the toast's id. Pass it to toast.close or toast.update.

Composition

Toaster renders all of this for you. Compose the parts yourself only when the built-in layout is not the one you need.

ToastProvider
└── ToastPortal
    └── ToastViewport            the fixed landmark the stack lives in
        └── Toast                one per toast in `useToastManager().toasts`
            └── ToastContent
                ├── ToastIcon
                ├── ToastTitle
                ├── ToastDescription
                ├── ToastAction
                └── ToastClose

Toasts stack. New toasts appear in front and older ones show below them at a smaller scale. The stack fans out while the pointer is over it or focus is inside it. A toast can be swiped down or to the right to dismiss it.

ToastTitle, ToastDescription and ToastAction read from the toast object and render nothing when it has no title, description or actionProps, so the built-in list needs no conditionals. Past the provider's limit, Base UI keeps the oldest toast mounted and marks it data-limited instead of removing it, so it can fade out in place.

Types

Set the type option to render a status icon. The built-in list recognises success, info, warning, error and loading.

import { Button } from "@/components/ui/button"
import { HStack } from "@/components/ui/stack"
import { toast } from "@/components/ui/toast"

export default function ToastTypes() {
  return (
    <HStack gap={2} wrap="wrap">
      <Button
        variant="outline"
        onClick={() => toast.add({ description: "Event has been created." })}
      >
        Default
      </Button>
      <Button
        variant="outline"
        onClick={() => toast.add({ type: "success", description: "Event has been created." })}
      >
        Success
      </Button>
      <Button
        variant="outline"
        onClick={() =>
          toast.add({ type: "info", description: "Arrive 10 minutes before the event." })
        }
      >
        Info
      </Button>
      <Button
        variant="outline"
        onClick={() =>
          toast.add({ type: "warning", description: "The event cannot start before 8:00 AM." })
        }
      >
        Warning
      </Button>
      <Button
        variant="outline"
        onClick={() =>
          toast.add({
            type: "error",
            description: "The event could not be created.",
            priority: "high",
          })
        }
      >
        Error
      </Button>
    </HStack>
  )
}

Pass priority: "high" for a message a screen reader should announce at once. A screen reader announces everything else politely, after it finishes what it is reading.

Action

Pass button props with actionProps to render an action.

const id = toast.add({
  title: "Event created",
  actionProps: {
    children: "Undo",
    onClick() {
      toast.close(id)
    },
  },
})

Promise

Use toast.promise to update one toast as an asynchronous task moves through its loading, success and error states.

import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"

export default function ToastPromise() {
  function showToast() {
    toast.promise(
      new Promise<{ name: string }>((resolve) => {
        window.setTimeout(() => resolve({ name: "Event" }), 2000)
      }),
      {
        loading: "Creating event…",
        success: (data) => `${data.name} created.`,
        error: "Could not create event.",
      },
    )
  }

  return (
    <Button variant="outline" onClick={showToast}>
      Create event
    </Button>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI Toast part of the same name and accepts its props. See the Base UI reference for the manager options, anchored toasts and the primitive API. Each styled part also takes xstyle and className. Toast and ToastContent take style too. It is real inline CSS, merged over whatever the stack's state resolves to.

Toaster

Toaster takes the props of ToastProvider.

PropTypeDefault
timeoutnumber5000
limitnumber3
toastManagerToastManagertoast

A timeout of 0 keeps a toast up until it is closed. The timer pauses while the stack is fanned out.

toast

MethodSignature
add(options) => id
close(id?) => void
update(id, options) => void
promise(promise, { loading, success, error }) => promise

options takes title, description, type, timeout, priority, actionProps, onClose, onRemove and data. Adding a toast with an id that is already showing updates it in place and restarts its timer.

Toast

PropTypeDefault
toastToastObject—
swipeDirection"up" | "down" | "left" | "right" or an array of them["down", "right"]
styleCSSProperties—

Data attributes

AttributeOnEffect
data-slot="toast-viewport"the viewportThe fixed landmark; F6 moves focus into it
data-slot="toast"the rootThe box that stacks, slides and swipes
data-slot="toast-content"the contentThe padded row; fades while the toast is behind another
data-slot="toast-icon"the iconPresent only for a recognised type
data-typethe rootThe toast's type, as given
data-expandedroot, viewport, contentBase UI writes it while the stack is fanned out
data-limitedthe rootBase UI writes it past the provider's limit
data-swiping, data-swipe-directionthe rootBase UI writes them during a swipe
data-behindthe contentBase UI writes it on every toast but the front one
data-starting-style, data-ending-stylethe rootBase UI writes them while the toast animates