Skip to contentVibraUI
Feedback & status

Toast

Brief notices stacked in a corner, raised from anywhere through one manager.

Base UI's toast manager as shadcn wires it, not sonner. Vibra draws each toast as a floating sheet — a hairline ring, --shadow-modal and the kit's focus outline — where shadcn uses a border and shadow-lg, and times its entry, stacking and exit on the motion tokens rather than a fixed 500ms. A loading toast's spinner holds still for a reader who has asked for less motion, by the OS setting or a [data-motion="reduced"] above. Mount Toaster once in the root layout; toast.add then works from any client code, and Notify is the one-call-per-intent API over the same manager. The stack is a polite live region that never takes the focus: F6 moves into it, Tab walks its toasts and Esc dismisses the one in focus. priority: "high" reads a toast out at once through role="alert", for a failure the reader must know about now. Toaster's position puts the stack in any corner or centred on either edge, where shadcn's stays bottom right — a top stack grows down and its toasts come in from above — and label names a second stack's region apart from the first.

Install

npx shadcn@latest add @vibra/toast

Needs the @vibra registry in your components.json — set it up once.

Examples

Success

A title and nothing more, beside the invoice it is about. It leaves on its own.

Error, announced at once

A declined card as a high-priority toast: read out at once through role="alert", and still taking no focus.

With Undo

Archive at once and offer the way back. Undo restores the order in place, and the focus moves on when a row leaves.

With a description

A short title and a description that carries the figures and what the next step will need.

Loading, then done

One toast from start to finish: it spins while the export runs, holding still under reduced motion, and turns into the result in place.

Stays until dismissed

A notice that matters until tonight, kept up with timeout 0. F6 reaches the stack and Esc dismisses it.

Updates in place

Each press reuses one id, so the toast already up changes its count instead of a new one stacking per order.

Stacked

Three results of one import stack newest first. Pointing at them or tabbing in fans them out and holds their timers.

Positioned

A second stack at the top centre, with a region name of its own. It grows down, and its toasts come in from above.

Props

PropTypeDefaultDescription
toast.add(options: { id?: string; title?: React.ReactNode; description?: React.ReactNode; type?: string; timeout?: number; priority?: "low" | "high"; actionProps?: React.ComponentProps<"button">; onClose?: () => void }) => string—Raises a toast and returns its id; type "success", "info", "warning", "error" or "loading" picks the icon. An id already up updates that toast in place, timeout: 0 keeps it until it is dismissed, and priority: "high" announces it at once.
toast.promise<T>(promise: Promise<T>, options: { loading; success; error }) => Promise<T>—Shows a loading toast and turns it into the success or error toast when the promise settles; each option is toast.add's options, or a function of the result.
toast.close(id: string) => void—Closes one toast by id.
Toaster.toastManagerToastManagertoastThe manager the stack listens to; one from createToastManager keeps a second stack apart.
Toaster.position"top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right""bottom-right"Where the stack sits from sm up; on a phone it spans the width on its edge. A top stack grows down.
Toaster.labelstring"Notifications"The name of the stack's region. A second stack on the page needs one of its own.

Dependencies

Source

components/ui/toast.tsx
"use client"

import * as React from "react"
import { Toast as ToastPrimitive } from "@base-ui/react/toast"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"

import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { Button } from "@/components/ui/button"
import { XIcon, CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"

const toast = ToastPrimitive.createToastManager()

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 ToastPosition = "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"

function sidesOf(position: ToastPosition) {
  const [side, align] = position.split("-") as ["top" | "bottom", "left" | "center" | "right"]
  return { side, align }
}

// Where the stack sits. The side also decides which way it grows and where a
// toast comes in from, and the viewport hands that to its toasts as custom
// properties — --toast-dir is -1 for a stack growing up from the bottom edge
// and 1 for one hanging from the top — so each toast needs no variant of its
// own. On a phone every stack spans the width; from sm it keeps to its corner.
const toastViewportVariants = cva(
  "pointer-events-none fixed inset-x-4 z-50 mx-auto w-auto max-w-sm outline-none sm:w-full",
  {
    variants: {
      side: {
        bottom: "bottom-4 [--toast-bottom:0px] [--toast-dir:-1] [--toast-origin:bottom] [--toast-top:auto]",
        top: "top-4 [--toast-bottom:auto] [--toast-dir:1] [--toast-origin:top] [--toast-top:0px]",
      },
      align: {
        left: "sm:right-auto sm:left-4 sm:mx-0",
        center: "",
        right: "sm:right-4 sm:left-auto sm:mx-0",
      },
    },
    defaultVariants: { side: "bottom", align: "right" },
  }
)

function ToastViewport({
  className,
  position = "bottom-right",
  ...props
}: ToastPrimitive.Viewport.Props & { position?: ToastPosition }) {
  return (
    <ToastPrimitive.Viewport
      data-slot="toast-viewport"
      data-position={position}
      className={cn(toastViewportVariants(sidesOf(position)), className)}
      {...props}
    />
  )
}

function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
  return (
    <ToastPrimitive.Root
      data-slot="toast"
      className={cn(
        // Anchored on the edge the viewport names, and moved along the way its
        // stack grows (--toast-dir); outside a kit viewport, the bottom edge.
        "group/toast pointer-events-auto absolute right-0 [top:var(--toast-top,auto)] [bottom:var(--toast-bottom,0px)] z-[calc(1000-var(--toast-index))] w-full [transform-origin:var(--toast-origin,bottom)] rounded-2xl bg-popover text-popover-foreground ring-1 ring-border shadow-(--shadow-modal) will-change-transform outline-none select-none focus-ring",
        "[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-dir,-1)*(var(--toast-offset-y)+var(--toast-index)*var(--gap))+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-dir,-1)*(var(--toast-index)*var(--peek)+var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_var(--duration-slow)_var(--ease-emphasized),opacity_var(--duration-slow),height_var(--duration-fast)]",
        "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(calc(var(--toast-dir,-1)*-150%))]",
        "[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(calc(var(--toast-dir,-1)*-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-center gap-3 overflow-hidden p-4 transition-opacity duration-(--duration-base) ease-(--ease-emphasized) data-behind:opacity-0 data-expanded:opacity-100",
        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-muted-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("shrink-0", className)}
      {...props}
    />
  )
}

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 toast"
      render={render}
      className={cn(
        "relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
        className
      )}
      {...props}
    >
      {children ?? (
        <XIcon aria-hidden="true" />
      )}
    </ToastPrimitive.Close>
  )
}

function ToastIcon({ type, still }: { type: string | undefined; still: boolean }) {
  let icon: React.ReactNode = null

  if (type === "success") {
    icon = (
      <CircleCheckIcon aria-hidden="true" />
    )
  }

  if (type === "info") {
    icon = (
      <InfoIcon aria-hidden="true" />
    )
  }

  if (type === "warning") {
    icon = (
      <TriangleAlertIcon aria-hidden="true" />
    )
  }

  if (type === "error") {
    icon = (
      <OctagonXIcon className="text-destructive" aria-hidden="true" />
    )
  }

  if (type === "loading") {
    icon = (
      <Loader2Icon
        // A spin is a loop, so no token can time it: it stops instead — here
        // for [data-motion="reduced"] as well as the OS setting.
        className={still ? undefined : "animate-spin motion-reduce:animate-none in-data-[motion=reduced]:animate-none"}
        aria-hidden="true"
      />
    )
  }

  if (!icon) {
    return null
  }

  return (
    <span
      data-slot="toast-icon"
      className="shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"
    >
      {icon}
    </span>
  )
}

function ToastList({ position = "bottom-right" }: { position?: ToastPosition }) {
  const { toasts } = ToastPrimitive.useToastManager()
  // Read once, where the stack mounts, so a toast raised later is still from
  // its first frame. Both requests for less motion count: the OS setting and
  // a [data-motion="reduced"] above.
  const reducedMotion = useReducedMotion()
  // A toast is flicked off the edge it came in from, or sideways out of its
  // corner.
  const { side, align } = sidesOf(position)
  const swipe: ("up" | "down" | "left" | "right")[] = [side === "top" ? "up" : "down", align === "left" ? "left" : "right"]

  return toasts.map((toastItem) => (
    <Toast key={toastItem.id} toast={toastItem} swipeDirection={swipe}>
      <ToastContent>
        <ToastIcon type={toastItem.type} still={reducedMotion} />
        <div className="flex min-w-0 flex-1 flex-col gap-1">
          <ToastTitle />
          <ToastDescription />
        </div>
        <ToastAction />
        <ToastClose />
      </ToastContent>
    </Toast>
  ))
}

function Toaster({
  children,
  toastManager = toast,
  position = "bottom-right",
  label = "Notifications",
  ...props
}: ToastPrimitive.Provider.Props & {
  /** Where the stack sits; a top stack grows down and its toasts come in from above. */
  position?: ToastPosition
  /** The stack's region name. A second stack on the page needs one of its own. */
  label?: string
}) {
  return (
    <ToastProvider toastManager={toastManager} {...props}>
      {children}
      <ToastPortal>
        <ToastViewport position={position} aria-label={label}>
          <ToastList position={position} />
        </ToastViewport>
      </ToastPortal>
    </ToastProvider>
  )
}

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

export type { ToastPosition }

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