# Toast

> Brief messages that stack neatly, expand on hover, swipe away, and turn a loading state into success or error in place.

Docs: https://hextaui.com/docs/toast
Markdown: https://hextaui.com/docs/toast.md

```tsx title="components/examples/toast/demo.tsx"
"use client"

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

export function ToastDemo() {
  return (
    <Button
      variant="outline"
      onClick={() =>
        toast("Event created", {
          description: "Sunday, December 3 at 9:00 AM",
          action: { label: "Undo", onClick: () => toast("Event removed") },
        })
      }
    >
      Show toast
    </Button>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/toast.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/toast.tsx"
"use client"

import * as React from "react"
import { Toast as ToastPrimitive } from "@base-ui/react/toast"
import {
  IconAlertTriangleFilled,
  IconCircleCheckFilled,
  IconCircleXFilled,
  IconInfoCircleFilled,
  IconX,
} from "@tabler/icons-react"
import { cn } from "cn"

import { buttonVariants } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"

type ToastType =
  "default" | "success" | "error" | "warning" | "info" | "loading"

type ToastPosition =
  | "top-left"
  | "top-center"
  | "top-right"
  | "bottom-left"
  | "bottom-center"
  | "bottom-right"

type ToastAction = {
  label: React.ReactNode
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
}

type ToastOptions = {
  id?: string
  description?: React.ReactNode
  timeout?: number
  priority?: "low" | "high"
  action?: ToastAction
  onClose?: () => void
  onRemove?: () => void
}

type ToastData = { action?: ToastAction }

const manager = ToastPrimitive.createToastManager<ToastData>()

let counter = 0

function nextId() {
  counter += 1
  return `toast-${counter}`
}

function textOf(node: React.ReactNode): string {
  if (typeof node === "string" || typeof node === "number") {
    return String(node)
  }
  if (Array.isArray(node)) {
    return node.map(textOf).join(" ")
  }
  if (React.isValidElement<{ children?: React.ReactNode }>(node)) {
    return textOf(node.props.children)
  }
  return ""
}

const readingFloor = 5000

function readingTimeout(title: React.ReactNode, description: React.ReactNode) {
  const words = `${textOf(title)} ${textOf(description)}`
    .split(/\s+/)
    .filter(Boolean).length
  const time = Math.min(15000, 2000 + words * 240)
  return time > readingFloor ? time : undefined
}

function toOptions(
  type: ToastType,
  title: React.ReactNode,
  { action, ...options }: ToastOptions = {}
) {
  return {
    ...options,
    type,
    title,
    timeout:
      type === "loading"
        ? 0
        : (options.timeout ?? readingTimeout(title, options.description)),
    data: { action },
  }
}

function show(type: ToastType, title: React.ReactNode, options?: ToastOptions) {
  const id = options?.id ?? nextId()
  manager.add({ id, ...toOptions(type, title, options) })
  return id
}

type PromiseState<Value> =
  | React.ReactNode
  | ((
      value: Value
    ) => React.ReactNode | (ToastOptions & { title: React.ReactNode }))

function resolveState<Value>(
  state: PromiseState<Value>,
  value: Value,
  type: ToastType
) {
  const result = typeof state === "function" ? state(value) : state
  if (
    result !== null &&
    typeof result === "object" &&
    !React.isValidElement(result) &&
    "title" in (result as object)
  ) {
    const { title, ...options } = result as ToastOptions & {
      title: React.ReactNode
    }
    return toOptions(type, title, options)
  }
  return toOptions(type, result as React.ReactNode)
}

const toast = Object.assign(
  (title: React.ReactNode, options?: ToastOptions) =>
    show("default", title, options),
  {
    success: (title: React.ReactNode, options?: ToastOptions) =>
      show("success", title, options),
    error: (title: React.ReactNode, options?: ToastOptions) =>
      show("error", title, { priority: "high", ...options }),
    warning: (title: React.ReactNode, options?: ToastOptions) =>
      show("warning", title, options),
    info: (title: React.ReactNode, options?: ToastOptions) =>
      show("info", title, options),
    loading: (title: React.ReactNode, options?: ToastOptions) =>
      show("loading", title, options),
    promise: <Value,>(
      promise: Promise<Value>,
      states: {
        loading: React.ReactNode
        success: PromiseState<Value>
        error: PromiseState<unknown>
      }
    ) => {
      const tracked = manager.promise(promise, {
        loading: toOptions("loading", states.loading),
        success: (value: Value) =>
          resolveState(states.success, value, "success"),
        error: (error: unknown) => resolveState(states.error, error, "error"),
      })
      promise.catch(() => undefined)
      tracked.catch(() => undefined)
      return tracked
    },
    update: (
      id: string,
      {
        type,
        title,
        ...options
      }: ToastOptions & {
        type?: ToastType
        title?: React.ReactNode
      }
    ) =>
      manager.update(id, {
        ...(type ? { type } : {}),
        ...(title !== undefined ? { title } : {}),
        ...(type && type !== "loading" && options.timeout === undefined
          ? {
              timeout:
                readingTimeout(title, options.description) ?? readingFloor,
            }
          : {}),
        ...options,
        ...(options.action ? { data: { action: options.action } } : {}),
      }),
    dismiss: (id?: string) => manager.close(id),
  }
)

const icons: Partial<Record<ToastType, React.ReactNode>> = {
  success: <IconCircleCheckFilled className="text-success" />,
  error: <IconCircleXFilled className="text-destructive" />,
  warning: <IconAlertTriangleFilled className="text-warning" />,
  info: <IconInfoCircleFilled className="text-info" />,
  loading: (
    <Spinner aria-hidden size={null} className="text-muted-foreground" />
  ),
}

function swipeFor(
  position: ToastPosition
): ToastPrimitive.Root.Props["swipeDirection"] {
  const vertical = position.startsWith("top") ? "up" : "down"
  if (position.endsWith("left")) {
    return [vertical, "left"]
  }
  if (position.endsWith("right")) {
    return [vertical, "right"]
  }
  return [vertical]
}

const rootClassName =
  "group/toast absolute inset-x-0 z-[calc(1000-var(--toast-index))] h-(--height) w-full rounded-xl bg-popover text-popover-foreground shadow-lg ring-(length:--hairline) forced-colors:border ring-foreground/10 select-none [--gap:0.625rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--peek:0.625rem] [--scale:calc(max(0,1-(var(--toast-index)*0.06)))] [--shrink:calc(1-var(--scale))] [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+var(--toast-dir)*(var(--toast-index)*var(--peek)+var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_450ms_cubic-bezier(0.22,1,0.36,1),opacity_350ms,height_200ms_cubic-bezier(0.22,1,0.36,1)] group-data-[position^=bottom]/toaster:bottom-0 group-data-[position^=bottom]/toaster:origin-bottom group-data-[position^=top]/toaster:top-0 group-data-[position^=top]/toaster:origin-top after:absolute after:inset-x-0 after:h-[calc(var(--gap)+1px)] group-data-[position^=bottom]/toaster:after:top-full group-data-[position^=top]/toaster:after:bottom-full data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-dir)*(var(--toast-offset-y)+var(--toast-index)*var(--gap))+var(--toast-swipe-movement-y)))] data-limited:opacity-0 data-starting-style:[transform:translateY(calc(var(--toast-dir)*-150%))] data-ending-style:opacity-0 [&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(calc(var(--toast-dir)*-150%))] data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))] data-ending-style:data-[swipe-direction=up]:[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%))] data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))] data-swiping:[transition:none] motion-reduce:[transition:opacity_200ms]"

function ToastItem({
  item,
  position,
}: {
  item: ToastPrimitive.Root.ToastObject<ToastData>
  position: ToastPosition
}) {
  const type = (item.type ?? "default") as ToastType
  const icon = icons[type]
  const action = item.data?.action

  return (
    <ToastPrimitive.Root
      toast={item}
      swipeDirection={swipeFor(position)}
      data-slot="toast"
      className={rootClassName}
    >
      <ToastPrimitive.Content
        data-slot="toast-content"
        className="flex items-start gap-3 overflow-hidden p-4 transition-opacity duration-250 data-behind:opacity-0 data-expanded:opacity-100"
      >
        {icon ? (
          <span
            key={type}
            data-slot="toast-icon"
            className="mt-px flex size-4 shrink-0 items-center justify-center motion-safe:animate-in motion-safe:animation-duration-300 motion-safe:fade-in-0 motion-safe:zoom-in-50 [&_svg]:size-4"
          >
            {icon}
          </span>
        ) : null}
        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <ToastPrimitive.Title
            data-slot="toast-title"
            className="text-sm leading-5 font-medium wrap-break-word"
          />
          <ToastPrimitive.Description
            data-slot="toast-description"
            className="text-sm leading-5 wrap-break-word text-muted-foreground"
          />
        </div>
        {action ? (
          <ToastPrimitive.Action
            data-slot="toast-action"
            className={cn(
              buttonVariants({ variant: "outline", size: "xs" }),
              "-my-0.5 shrink-0"
            )}
            onClick={(event) => {
              action.onClick?.(event)
              if (!event.defaultPrevented) {
                manager.close(item.id)
              }
            }}
          >
            {action.label}
          </ToastPrimitive.Action>
        ) : null}
        <ToastPrimitive.Close
          data-slot="toast-close"
          aria-label="Dismiss"
          className="relative -me-1.5 -mt-1 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity duration-150 outline-none group-hover/toast:opacity-100 after:absolute after:-inset-1.5 hover:bg-muted hover:text-foreground focus-visible:opacity-100 focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden pointer-coarse:opacity-100 [&_svg]:size-3.5"
        >
          <IconX />
        </ToastPrimitive.Close>
      </ToastPrimitive.Content>
    </ToastPrimitive.Root>
  )
}

function ToastList({ position }: { position: ToastPosition }) {
  const { toasts } = ToastPrimitive.useToastManager<ToastData>()
  return toasts.map((item) => (
    <ToastItem key={item.id} item={item} position={position} />
  ))
}

const positionClassName: Record<ToastPosition, string> = {
  "top-left": "top-4 sm:top-6 sm:left-6",
  "top-center": "top-4 sm:top-6 sm:left-1/2 sm:-translate-x-1/2",
  "top-right": "top-4 sm:top-6 sm:right-6",
  "bottom-left": "bottom-4 sm:bottom-6 sm:left-6",
  "bottom-center": "bottom-4 sm:bottom-6 sm:left-1/2 sm:-translate-x-1/2",
  "bottom-right": "bottom-4 sm:bottom-6 sm:right-6",
}

type ToasterProps = Omit<
  ToastPrimitive.Viewport.Props,
  "children" | "className"
> & {
  className?: string
  position?: ToastPosition
  limit?: number
  timeout?: number
}

function Toaster({
  className,
  position = "bottom-right",
  limit = 3,
  timeout = 5000,
  ...props
}: ToasterProps) {
  return (
    <ToastPrimitive.Provider
      toastManager={manager}
      limit={limit}
      timeout={timeout}
    >
      <ToastPrimitive.Portal>
        <ToastPrimitive.Viewport
          data-slot="toaster"
          data-position={position}
          className={cn(
            "group/toaster fixed inset-x-4 z-100 mx-auto outline-none focus-visible:outline-hidden data-[position^=bottom]:[--toast-dir:-1] data-[position^=top]:[--toast-dir:1] sm:inset-x-auto sm:w-[22.5rem]",
            positionClassName[position],
            className
          )}
          {...props}
        >
          <ToastList position={position} />
        </ToastPrimitive.Viewport>
      </ToastPrimitive.Portal>
    </ToastPrimitive.Provider>
  )
}

export { Toaster, toast }
export type {
  ToastAction,
  ToastOptions,
  ToastPosition,
  ToasterProps,
  ToastType,
}
```

```tsx title="components/ui/button.tsx"
"use client"

import * as React from "react"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { Spinner } from "@/components/ui/spinner"
import {
  useButtonFeedback,
  type ButtonFeedbackOptions,
  type ButtonStatus,
} from "@/hooks/use-button-feedback"

const buttonVariants = cva(
  "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/80",
        outline:
          "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
        ghost:
          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0",
        destructive:
          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70",
        link: "text-primary underline-offset-4 hover:underline forced-colors:border-0",
        success:
          "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40",
        error:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake",
      },
      size: {
        default:
          "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
        xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
        sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5",
        lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
        icon: "size-9 shrink-0",
        "icon-xs":
          "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3",
        "icon-sm":
          "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden",
        "icon-lg": "size-10 shrink-0",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

type ButtonVariant = Exclude<
  NonNullable<VariantProps<typeof buttonVariants>["variant"]>,
  "success" | "error"
>

type ButtonClickEvent = Parameters<
  NonNullable<ButtonPrimitive.Props["onClick"]>
>[0]

type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> &
  Omit<VariantProps<typeof buttonVariants>, "variant"> &
  ButtonFeedbackOptions & {
    variant?: ButtonVariant | null
    onClick?: (event: ButtonClickEvent) => unknown
    feedback?: boolean
    loading?: boolean
    status?: ButtonStatus
    loadingLabel?: React.ReactNode
    successLabel?: React.ReactNode
    errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode)
  }

const stateTransitionTime = 220
const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"]
const quietVariants: ButtonVariant[] = ["ghost", "link"]
const quietStatusClasses: Partial<Record<ButtonStatus, string>> = {
  success: "text-success hover:text-success",
  error:
    "text-destructive hover:text-destructive motion-safe:animate-button-shake",
}

function isThenable(value: unknown): value is PromiseLike<unknown> {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as PromiseLike<unknown>).then === "function"
  )
}

function useStatusTransition(status: ButtonStatus) {
  const [shown, setShown] = React.useState(status)
  const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null)
  const [transitionKey, setTransitionKey] = React.useState(0)

  if (status !== shown) {
    setLeaving(shown)
    setShown(status)
    setTransitionKey((key) => key + 1)
  }

  React.useEffect(() => {
    if (leaving === null) {
      return
    }
    const timer = setTimeout(() => setLeaving(null), stateTransitionTime)
    return () => clearTimeout(timer)
  }, [leaving, transitionKey])

  return { leaving, transitionKey }
}

function useLayerWidth(status: ButtonStatus, active: boolean) {
  const contentRef = React.useRef<HTMLSpanElement>(null)
  const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>())
  const observerRef = React.useRef<ResizeObserver | null>(null)
  const statusRef = React.useRef(status)

  const applyWidth = React.useCallback(() => {
    const content = contentRef.current
    const layer = layersRef.current.get(statusRef.current)
    if (!content || !layer) {
      return
    }
    const natural = parseFloat(getComputedStyle(layer).width)
    if (!Number.isFinite(natural)) {
      return
    }
    content.style.width = `${natural}px`
    if (!content.hasAttribute("data-measured")) {
      requestAnimationFrame(() => content.setAttribute("data-measured", ""))
    }
  }, [])

  React.useLayoutEffect(() => {
    statusRef.current = status
    if (active) {
      applyWidth()
    }
  }, [active, applyWidth, status])

  React.useLayoutEffect(() => {
    if (!active || typeof ResizeObserver === "undefined") {
      return
    }
    const observer = new ResizeObserver(applyWidth)
    observerRef.current = observer
    layersRef.current.forEach((layer) => observer.observe(layer))
    document.fonts?.addEventListener?.("loadingdone", applyWidth)
    return () => {
      observer.disconnect()
      observerRef.current = null
      document.fonts?.removeEventListener?.("loadingdone", applyWidth)
    }
  }, [active, applyWidth])

  const layerRef = React.useCallback((node: HTMLElement | null) => {
    if (!node) {
      return
    }
    const layer = node.dataset.layer as ButtonStatus
    layersRef.current.set(layer, node)
    observerRef.current?.observe(node)
    return () => {
      observerRef.current?.unobserve(node)
      if (layersRef.current.get(layer) === node) {
        layersRef.current.delete(layer)
      }
    }
  }, [])

  return { contentRef, layerRef }
}

function composeHandlers<Event>(
  first: ((event: Event) => void) | undefined,
  second: (event: Event) => void
) {
  return (event: Event) => {
    first?.(event)
    second(event)
  }
}

function Button({
  className,
  variant = "default",
  size = "default",
  children,
  onClick,
  disabled,
  focusableWhenDisabled,
  feedback,
  loading,
  status: statusProp,
  onStatusChange,
  onError,
  resetAfter,
  loadingLabel,
  successLabel = "Done",
  errorLabel = "Failed",
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: ButtonProps) {
  const internal = useButtonFeedback({ resetAfter, onStatusChange, onError })
  const status = statusProp ?? (loading ? "loading" : internal.status)
  const requested =
    Boolean(feedback) || loading !== undefined || statusProp !== undefined
  const [enabled, setEnabled] = React.useState(requested)
  if (requested && !enabled) {
    setEnabled(true)
  }

  const { leaving, transitionKey } = useStatusTransition(status)
  const iconOnly = size?.startsWith("icon") ?? false
  const widthStatus =
    status === "loading" && loadingLabel === undefined ? "idle" : status
  const { contentRef, layerRef } = useLayerWidth(
    widthStatus,
    enabled && !iconOnly
  )
  const isLoading = status === "loading"
  const quiet = quietVariants.includes(variant ?? "default")
  const resolvedVariant =
    !quiet && status === "success"
      ? "success"
      : !quiet && status === "error"
        ? "error"
        : variant
  const resolvedErrorLabel =
    typeof errorLabel !== "function"
      ? errorLabel
      : internal.error !== undefined
        ? errorLabel(internal.error)
        : "Failed"

  const handleClick = (event: ButtonClickEvent) => {
    if (feedback && internal.isPending()) {
      return
    }
    const result = onClick?.(event)
    if (feedback && isThenable(result)) {
      internal.track(result)
    }
  }

  const layerContent = (layer: ButtonStatus, animated: boolean) => {
    if (layer === "idle") {
      return children
    }
    if (layer === "loading") {
      return (
        <>
          <Spinner aria-hidden size={null} animated={animated} />
          {iconOnly ? null : loadingLabel}
        </>
      )
    }
    if (layer === "success") {
      return (
        <>
          <IconCircleCheck
            className={cn(
              animated &&
                "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]"
            )}
          />
          {iconOnly ? null : successLabel}
        </>
      )
    }
    return (
      <>
        <IconAlertCircle />
        {iconOnly ? null : resolvedErrorLabel}
      </>
    )
  }

  const button = (
    <ButtonPrimitive
      data-slot="button"
      data-status={enabled ? status : undefined}
      aria-busy={isLoading || undefined}
      disabled={disabled || isLoading}
      focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)}
      className={cn(
        buttonVariants({ variant: resolvedVariant, size }),
        quiet && quietStatusClasses[status],
        className
      )}
      onClick={handleClick}
      onPointerEnter={composeHandlers(
        onPointerEnter,
        internal.buttonProps.onPointerEnter
      )}
      onPointerLeave={composeHandlers(
        onPointerLeave,
        internal.buttonProps.onPointerLeave
      )}
      onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)}
      onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)}
      {...props}
    >
      {enabled ? (
        <span
          ref={contentRef}
          data-slot="button-content"
          className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]"
        >
          {statuses.map((layer) => {
            const active = layer === status
            const exiting = layer === leaving
            const namesButton =
              layer === "idle" && isLoading && loadingLabel === undefined

            return (
              <span
                key={active ? `${layer}-${transitionKey}` : layer}
                ref={layerRef}
                data-slot="button-layer"
                data-layer={layer}
                aria-hidden={active || namesButton ? undefined : true}
                className={cn(
                  "inline-flex items-center justify-center gap-[inherit] backface-hidden",
                  !active &&
                    !exiting &&
                    "pointer-events-none absolute opacity-0",
                  active &&
                    transitionKey > 0 &&
                    "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0",
                  exiting &&
                    "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out"
                )}
              >
                {layerContent(layer, active || exiting)}
              </span>
            )
          })}
        </span>
      ) : (
        children
      )}
    </ButtonPrimitive>
  )

  if (!enabled) {
    return button
  }

  const announcement =
    status === "loading"
      ? typeof loadingLabel === "string"
        ? loadingLabel
        : "Loading"
      : status === "success"
        ? typeof successLabel === "string"
          ? successLabel
          : "Done"
        : status === "error"
          ? typeof resolvedErrorLabel === "string"
            ? resolvedErrorLabel
            : "Failed"
          : ""

  return (
    <>
      {button}
      <span data-slot="button-status" role="status" className="sr-only">
        {announcement}
      </span>
    </>
  )
}

export { Button, buttonVariants, useButtonFeedback }
export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }
```

```tsx title="components/ui/spinner.tsx"
"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import {
  useDelayedLoading,
  type DelayedLoadingOptions,
} from "@/hooks/use-delayed-loading"

const spinnerVariants = cva("shrink-0", {
  variants: {
    size: {
      sm: "size-3.5",
      default: "size-4",
      lg: "size-5",
      xl: "size-8",
    },
  },
  defaultVariants: {
    size: "default",
  },
})

type SpinnerVariant = "ticks" | "ring"

type SpinnerProps = Omit<React.ComponentProps<"svg">, "children"> &
  VariantProps<typeof spinnerVariants> & {
    variant?: SpinnerVariant
    label?: string
    animated?: boolean
    loading?: boolean
    delay?: number
    minDuration?: number
  }

const ticks = Array.from({ length: 8 }, (_, index) => index)

function Spinner({
  className,
  variant = "ticks",
  size = "default",
  label = "Loading",
  animated = true,
  loading,
  delay,
  minDuration,
  ...props
}: SpinnerProps) {
  const delayed = useDelayedLoading(loading ?? false, { delay, minDuration })
  const shown = loading === undefined || delayed

  if (!shown) {
    return null
  }

  const hidden =
    props["aria-hidden"] === true || props["aria-hidden"] === "true"

  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      role={hidden ? undefined : "status"}
      aria-label={hidden ? undefined : label}
      data-slot="spinner"
      data-variant={variant}
      className={cn(
        spinnerVariants({ size }),
        animated &&
          (variant === "ticks"
            ? "motion-safe:animate-spinner-ticks"
            : "motion-safe:animate-spinner-rotate"),
        animated && "motion-reduce:animate-spinner-pulse",
        loading !== undefined &&
          "transition-opacity duration-200 ease-out motion-reduce:transition-none starting:opacity-0",
        className
      )}
      {...props}
    >
      {variant === "ticks" ? (
        ticks.map((index) => (
          <line
            key={index}
            x1="12"
            y1="2.75"
            x2="12"
            y2="6.75"
            stroke="currentColor"
            strokeWidth="2.25"
            strokeLinecap="round"
            opacity={1 - index * 0.11}
            transform={`rotate(${-index * 45} 12 12)`}
          />
        ))
      ) : (
        <>
          <circle
            cx="12"
            cy="12"
            r="9"
            stroke="currentColor"
            strokeWidth="2.5"
            opacity="0.2"
          />
          <circle
            cx="12"
            cy="12"
            r="9"
            stroke="currentColor"
            strokeWidth="2.5"
            strokeLinecap="round"
            strokeDasharray="16 60"
            transform="rotate(-90 12 12)"
            className={cn(animated && "motion-safe:animate-spinner-dash")}
          />
        </>
      )}
    </svg>
  )
}

export { Spinner, spinnerVariants, useDelayedLoading }
export type { DelayedLoadingOptions, SpinnerProps, SpinnerVariant }
```

Update the import paths to match your project setup.

## Usage

Add `<Toaster />` once, near the root of your app.

```tsx
import { Toaster } from "@/components/ui/toast"

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Toaster />
      </body>
    </html>
  )
}
```

Then call `toast` from anywhere, including outside React components.

```tsx
import { toast } from "@/components/ui/toast"

toast("Event created", { description: "Sunday at 9:00 AM" })
toast.success("Payment received")
toast.promise(save(), { loading: "Saving…", success: "Saved", error: "Couldn't save" })
```

Toasts stack into a neat pile with the newest in front. Hover or focus the pile to fan it out; every timer pauses while you read. Swipe a toast toward its edge to dismiss it.

## Examples

### Types

Every toast uses the same calm surface; only the icon carries the color.

```tsx title="components/examples/toast/types.tsx"
"use client"

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

export function ToastTypes() {
  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Button variant="outline" size="sm" onClick={() => toast("Draft saved")}>
        Default
      </Button>
      <Button
        variant="outline"
        size="sm"
        onClick={() => toast.success("Payment received")}
      >
        Success
      </Button>
      <Button
        variant="outline"
        size="sm"
        onClick={() =>
          toast.error("Couldn't upload photo", {
            description: "The file is larger than 10 MB.",
          })
        }
      >
        Error
      </Button>
      <Button
        variant="outline"
        size="sm"
        onClick={() => toast.warning("Storage almost full")}
      >
        Warning
      </Button>
      <Button
        variant="outline"
        size="sm"
        onClick={() => toast.info("A new version is available")}
      >
        Info
      </Button>
    </div>
  )
}
```

### Promise

`toast.promise` shows a spinner, then turns into a success or error toast in place, with the icon popping in as it changes.

```tsx title="components/examples/toast/promise.tsx"
"use client"

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

let attempt = 0

function deploy() {
  attempt += 1
  const fails = attempt % 2 === 0
  return new Promise<{ url: string }>((resolve, reject) =>
    setTimeout(
      () =>
        fails
          ? reject(new Error("Build failed"))
          : resolve({ url: "hextaui.com" }),
      1800
    )
  )
}

export function ToastPromise() {
  return (
    <Button
      variant="outline"
      onClick={() =>
        toast.promise(deploy(), {
          loading: "Deploying…",
          success: (data) => ({
            title: "Deployed",
            description: `Live at ${data.url}`,
          }),
          error: (error) => ({
            title: "Deploy failed",
            description: error instanceof Error ? error.message : "Try again.",
          }),
        })
      }
    >
      Deploy (fails every other time)
    </Button>
  )
}
```

### Action

Pass `action` for a button such as Undo. Clicking it also dismisses the toast.

```tsx title="components/examples/toast/action.tsx"
"use client"

import * as React from "react"

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

export function ToastAction() {
  const [archived, setArchived] = React.useState(0)

  return (
    <div className="flex flex-col items-center gap-3">
      <Button
        variant="outline"
        onClick={() => {
          setArchived((count) => count + 1)
          toast("Conversation archived", {
            action: {
              label: "Undo",
              onClick: () => setArchived((count) => count - 1),
            },
          })
        }}
      >
        Archive
      </Button>
      <p className="text-sm text-muted-foreground tabular-nums">
        {archived} archived
      </p>
    </div>
  )
}
```

### Update in place

Keep the id from `toast.loading` and change it with `toast.update`, including its type.

```tsx title="components/examples/toast/update.tsx"
"use client"

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

export function ToastUpdate() {
  return (
    <Button
      variant="outline"
      onClick={() => {
        const id = toast.loading("Uploading 3 files…")
        setTimeout(() => toast.update(id, { title: "Uploading 2 of 3…" }), 900)
        setTimeout(
          () =>
            toast.update(id, {
              type: "success",
              title: "Upload complete",
              description: "3 files added to Photos.",
            }),
          1800
        )
      }}
    >
      Upload files
    </Button>
  )
}
```

### No duplicates

Give a toast an `id` and calling it again updates the existing one instead of stacking a copy.

```tsx title="components/examples/toast/dedupe.tsx"
"use client"

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

export function ToastDedupe() {
  return (
    <Button
      variant="outline"
      onClick={() =>
        toast.info("You're offline", {
          id: "offline",
          description: "Changes will sync when you reconnect.",
        })
      }
    >
      Go offline (click twice)
    </Button>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `F6` | Moves focus into the toasts and fans them out. |
| `Tab` | Moves between toast actions and close buttons. |
| `Esc` | Dismisses the focused toast. |

## Accessibility

- Toasts are announced politely; `toast.error` is announced right away.
- Timers pause while the pointer or focus is on the toasts, so nobody has to race a countdown.
- Close buttons appear on hover and focus, and are always visible on touch screens.
- With reduced motion, toasts fade instead of sliding.

## API reference

### Toaster

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `position` | `"top-left" \| "top-center" \| "top-right" \| "bottom-left" \| "bottom-center" \| "bottom-right"` | `"bottom-right"` | Phones always use the full width. |
| `limit` | `number` | `3` | Toasts shown at once; older ones fade out. |
| `timeout` | `number` | `5000` | Default time on screen, in ms. |

| Attribute | Description |
| --- | --- |
| `data-slot="toaster"` | The viewport, with data-position. |
| `data-slot="toast"` | Each toast, with data-type, data-expanded and data-swiping. |

### toast()

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `description` | `ReactNode` | – |  |
| `action` | `{ label: ReactNode; onClick?: (event) => void }` | – |  |
| `id` | `string` | – | Reuse to update instead of duplicating. |
| `timeout` | `number` | – | Defaults to the Toaster timeout. Long messages stay up longer, about 250 words a minute. |
| `priority` | `"low" \| "high"` | `"low"` |  |
| `onClose` | `() => void` | – |  |
| `onRemove` | `() => void` | – |  |

Also `toast.success`, `toast.error`, `toast.warning`, `toast.info`, `toast.loading`, `toast.promise`, `toast.update` and `toast.dismiss`. Each returns or takes the toast's id.

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
