Brief messages that stack neatly, expand on hover, swipe away, and turn a loading state into success or error in place.
"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>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/toast.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react cn - 3
Copy and paste the following code into your project.
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, }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 }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 } - 4
Update the import paths to match your project setup.
Add <Toaster /> once, near the root of your app.
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.
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.
Types#
Every toast uses the same calm surface; only the icon carries the color.
"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.
"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.
0 archived
"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.
"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.
"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>
)
}| 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. |
- Toasts are announced politely;
toast.erroris 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.
| Prop | Type | Default |
|---|---|---|
positionPhones always use the full width. | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right" | "bottom-right" |
limitToasts shown at once; older ones fade out. | number | 3 |
timeoutDefault time on screen, in ms. | number | 5000 |
| Attribute | Description |
|---|---|
data-slot="toaster" | The viewport, with data-position. |
data-slot="toast" | Each toast, with data-type, data-expanded and data-swiping. |
| Prop | Type | Default |
|---|---|---|
description | ReactNode | – |
action | { label: ReactNode; onClick?: (event) => void } | – |
idReuse to update instead of duplicating. | string | – |
timeoutDefaults to the Toaster timeout. Long messages stay up longer, about 250 words a minute. | number | – |
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.