Inline messages for status and feedback, with a neutral surface, a colored icon and a dismiss that collapses smoothly.
import { IconCircleX, IconTerminal2 } from "@tabler/icons-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
export function AlertDemo() {
return (
<div className="flex w-full max-w-lg flex-col gap-3">
<Alert>
<IconTerminal2 />
<AlertTitle>Heads up</AlertTitle>
<AlertDescription>
You can add components to your app using the CLI.
</AlertDescription>
</Alert>
<Alert variant="destructive">
<IconCircleX />
<AlertTitle>Payment failed</AlertTitle>
<AlertDescription>
Your card was declined. Update your billing details.
</AlertDescription>
</Alert>
</div>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/alert.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 class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/alert.tsx "use client" import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { IconX } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { easeOut, easeSpring } from "@/lib/motion" import { Button } from "@/components/ui/button" const alertVariants = cva( "grid w-full grid-cols-[auto_minmax(0,1fr)_fit-content(50%)_auto] items-center rounded-lg px-4 py-3 text-sm text-card-foreground inset-ring-(length:--hairline) inset-ring-border has-[>[data-slot=alert-description]]:items-start forced-colors:border [&>svg]:col-start-1 [&>svg]:row-start-1 [&>svg]:me-3 [&>svg]:shrink-0 has-[>[data-slot=alert-description]]:[&>svg]:mt-0.5 [&>svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "[&>svg]:text-foreground", destructive: "[&>svg]:text-destructive", success: "[&>svg]:text-success", info: "[&>svg]:text-info", warning: "[&>svg]:text-warning", }, appearance: { outline: "bg-card", soft: "inset-ring-transparent [&>[data-slot=alert-close]]:hover:bg-foreground/5 [&>[data-slot=alert-description]]:text-foreground/75", }, }, compoundVariants: [ { appearance: "soft", variant: "default", className: "bg-muted" }, { appearance: "soft", variant: "destructive", className: "bg-destructive/8 dark:bg-destructive/15", }, { appearance: "soft", variant: "success", className: "bg-success/8 dark:bg-success/15", }, { appearance: "soft", variant: "info", className: "bg-info/8 dark:bg-info/15", }, { appearance: "soft", variant: "warning", className: "bg-warning/8 dark:bg-warning/15", }, ], defaultVariants: { variant: "default", appearance: "outline", }, } ) type AlertVariant = NonNullable<VariantProps<typeof alertVariants>["variant"]> type AlertAppearance = NonNullable< VariantProps<typeof alertVariants>["appearance"] > const assertiveVariants: AlertVariant[] = ["destructive", "warning"] const AlertContext = React.createContext<{ close: () => void } | null>(null) function useAlertContext(part: string) { const context = React.useContext(AlertContext) if (!context) { throw new Error(`<${part}> must be used within <Alert>.`) } return context } const focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]' function focusNearest(from: HTMLElement) { const candidates = Array.from( document.querySelectorAll<HTMLElement>(focusableSelector) ).filter((node) => !from.contains(node) && node.checkVisibility()) const target = candidates.find( (node) => from.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING ) ?? candidates.findLast( (node) => from.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_PRECEDING ) target?.focus() } function dataAttributes(slot: string, extra?: Record<string, string>) { return { "data-slot": slot, ...extra } as Record<string, string> } function collapsedMargins(element: HTMLElement) { const parent = element.parentElement const hasSiblings = Boolean( element.previousElementSibling || element.nextElementSibling ) let gap = 0 if (parent && hasSiblings) { const style = getComputedStyle(parent) const stacksVertically = style.display.includes("grid") || (style.display.includes("flex") && style.flexDirection.startsWith("column")) if (stacksVertically) { gap = parseFloat(style.rowGap) || 0 } } return element.previousElementSibling ? { marginBlockStart: `${-gap}px`, marginBlockEnd: "0px" } : { marginBlockStart: "0px", marginBlockEnd: `${-gap}px` } } function subscribe() { return () => {} } type AlertProps = useRender.ComponentProps<"div"> & { variant?: AlertVariant appearance?: AlertAppearance open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void } function Alert({ className, variant = "default", appearance = "outline", open: openProp, defaultOpen = true, onOpenChange, render, ref, ...props }: AlertProps) { const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen) const open = openProp ?? uncontrolledOpen const [present, setPresent] = React.useState(open) const elementRef = React.useRef<HTMLDivElement>(null) const isHydrating = React.useSyncExternalStore( subscribe, () => false, () => true ) const [animateIn, setAnimateIn] = React.useState(!isHydrating) if (open && !present) { setPresent(true) } React.useEffect(() => { const element = elementRef.current if (open || !present || !element) { return } if (typeof element.animate !== "function") { setPresent(false) return } const hadFocus = element.contains(document.activeElement) const reduceMotion = typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches element.style.overflow = "hidden" element.dataset.endingStyle = "" const animation = element.animate( [ { height: `${element.offsetHeight}px`, }, { height: "0px", paddingBlock: "0px", borderBlockWidth: "0px", ...collapsedMargins(element), }, ], { duration: reduceMotion ? 0 : 260, easing: easeSpring, fill: "forwards", } ) const fade = element.animate( [ { opacity: 1, scale: 1 }, { opacity: 0, scale: 0.98 }, ], { duration: reduceMotion ? 0 : 140, easing: easeOut, fill: "forwards", } ) animation.onfinish = () => { if (hadFocus) { focusNearest(element) } setAnimateIn(true) setPresent(false) } return () => { animation.onfinish = null animation.cancel() fade.cancel() element.style.overflow = "" delete element.dataset.endingStyle } }, [open, present]) const context = React.useMemo( () => ({ close: () => { if (openProp === undefined) { setUncontrolledOpen(false) } onOpenChange?.(false) }, }), [openProp, onOpenChange] ) const element = useRender({ defaultTagName: "div", render, ref: ref ? [elementRef, ref] : elementRef, enabled: present, props: mergeProps<"div">( { role: assertiveVariants.includes(variant) ? "alert" : "status", className: cn( alertVariants({ variant, appearance }), animateIn && "motion-safe:animate-in motion-safe:ease-out-quint motion-safe:animation-duration-200 motion-safe:fade-in-0 motion-safe:slide-in-from-top-1", className ), }, props, dataAttributes("alert", { "data-variant": variant, "data-appearance": appearance, }) ), }) return ( <AlertContext.Provider value={context}>{element}</AlertContext.Provider> ) } function AlertTitle({ className, render, ...props }: useRender.ComponentProps<"div">) { return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn( "col-start-2 min-w-0 leading-5 font-medium text-pretty wrap-anywhere", className ), }, props, dataAttributes("alert-title") ), }) } function AlertDescription({ className, render, ...props }: useRender.ComponentProps<"div">) { return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn( "col-start-2 min-w-0 leading-5 text-pretty wrap-anywhere text-muted-foreground [&_a:not([data-slot])]:text-foreground [&_a:not([data-slot])]:underline [&_a:not([data-slot])]:decoration-current/40 [&_a:not([data-slot])]:underline-offset-3 [&_a:not([data-slot])]:transition-[text-decoration-color] [&_a:not([data-slot])]:duration-150 [&_a:not([data-slot])]:hover:decoration-current [&>p:not(:last-child)]:mb-2 [[data-slot=alert-title]+&]:mt-0.5", className ), }, props, dataAttributes("alert-description") ), }) } function AlertAction({ className, render, ...props }: useRender.ComponentProps<"div">) { return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn( "col-start-3 row-start-1 ms-3 flex h-5 items-center gap-2 self-start", className ), }, props, dataAttributes("alert-action") ), }) } type AlertCloseProps = Omit< React.ComponentProps<typeof Button>, "className" > & { className?: string } function AlertClose({ className, children, onClick, "aria-label": ariaLabel = "Dismiss", ...props }: AlertCloseProps) { const { close } = useAlertContext("AlertClose") return ( <Button variant="ghost" size="icon-xs" aria-label={ariaLabel} className={cn( "col-start-4 row-start-1 -my-0.5 ms-2 -me-1.5 self-start text-muted-foreground hover:text-foreground", className )} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) { close() } }} {...props} data-slot="alert-close" > {children ?? <IconX />} </Button> ) } export { Alert, AlertTitle, AlertDescription, AlertAction, AlertClose, alertVariants, }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 }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import {
Alert,
AlertAction,
AlertClose,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert"<Alert>
<IconTerminal2 />
<AlertTitle>Heads up</AlertTitle>
<AlertDescription>
You can add components to your app using the CLI.
</AlertDescription>
</Alert>Alert
├── AlertTitle
├── AlertDescription
├── AlertAction
└── AlertCloseVariants#
variant colors only the icon, so the surface stays neutral and status reads at a glance without shouting.
import {
IconAlertTriangle,
IconCircleCheck,
IconCircleX,
IconInfoCircle,
IconTerminal2,
} from "@tabler/icons-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
const alerts = [
{
variant: "default",
icon: IconTerminal2,
title: "Heads up",
description: "You can add components to your app using the CLI.",
},
{
variant: "destructive",
icon: IconCircleX,
title: "Payment failed",
description: "Your card was declined. Update your billing details.",
},
{
variant: "success",
icon: IconCircleCheck,
title: "Changes saved",
description: "Your profile is now visible to your team.",
},
{
variant: "info",
icon: IconInfoCircle,
title: "New version available",
description: "Reload the page to get the latest features.",
},
{
variant: "warning",
icon: IconAlertTriangle,
title: "Trial ends in 3 days",
description: "Add a payment method to keep your workspace active.",
},
] as const
export function AlertVariants() {
return (
<div className="flex w-full max-w-lg flex-col gap-3">
{alerts.map(({ variant, icon: Icon, title, description }) => (
<Alert key={variant} variant={variant}>
<Icon />
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{description}</AlertDescription>
</Alert>
))}
</div>
)
}Soft#
appearance="soft" drops the border and tints the whole surface with the variant color.
import {
IconAlertTriangle,
IconCircleCheck,
IconCircleX,
IconInfoCircle,
IconTerminal2,
} from "@tabler/icons-react"
import {
Alert,
AlertAction,
AlertClose,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
const alerts = [
{
variant: "default",
icon: IconTerminal2,
title: "Heads up",
description: "You can add components to your app using the CLI.",
},
{
variant: "destructive",
icon: IconCircleX,
title: "Payment failed",
description: "Your card was declined. Update your billing details.",
},
{
variant: "success",
icon: IconCircleCheck,
title: "Changes saved",
description: "Your profile is now visible to your team.",
},
{
variant: "info",
icon: IconInfoCircle,
title: "New version available",
description: "Reload the page to get the latest features.",
},
{
variant: "warning",
icon: IconAlertTriangle,
title: "Trial ends in 3 days",
description: "Add a payment method to keep your workspace active.",
},
] as const
export function AlertSoft() {
return (
<div className="flex w-full max-w-lg flex-col gap-3">
{alerts.map(({ variant, icon: Icon, title, description }) => (
<Alert key={variant} variant={variant} appearance="soft">
<Icon />
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{description}</AlertDescription>
</Alert>
))}
<Alert variant="success" appearance="soft">
<IconCircleCheck />
<AlertTitle>Message archived</AlertTitle>
<AlertAction>
<Button variant="ghost" size="xs">
Undo
</Button>
</AlertAction>
<AlertClose />
</Alert>
</div>
)
}Partial content#
Title only, description only and no icon all line up without extra classes. The grid places each part in its own column.
import { IconCircleCheck, IconInfoCircle } from "@tabler/icons-react"
import {
Alert,
AlertClose,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert"
export function AlertPartialContent() {
return (
<div className="flex w-full max-w-lg flex-col gap-3">
<Alert variant="success">
<IconCircleCheck />
<AlertTitle>Invite sent</AlertTitle>
<AlertClose />
</Alert>
<Alert>
<IconInfoCircle />
<AlertDescription>
Sessions expire after 30 days of inactivity.
</AlertDescription>
</Alert>
<Alert>
<AlertTitle>No icon</AlertTitle>
<AlertDescription>The content starts at the padding.</AlertDescription>
</Alert>
</div>
)
}Actions#
Put buttons in <AlertAction />. They align with the first line of the title, even when it wraps.
import { IconInfoCircle, IconTerminal2 } from "@tabler/icons-react"
import {
Alert,
AlertAction,
AlertClose,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
export function AlertActions() {
return (
<div className="flex w-full max-w-lg flex-col gap-3">
<Alert variant="info">
<IconInfoCircle />
<AlertTitle>
A new version of the dashboard is available with faster charts, saved
filters and a redesigned sidebar
</AlertTitle>
<AlertDescription>
Reloading keeps your current filters.
</AlertDescription>
<AlertAction>
<Button size="xs">Reload</Button>
</AlertAction>
</Alert>
<Alert>
<IconTerminal2 />
<AlertTitle>Message archived</AlertTitle>
<AlertAction>
<Button variant="secondary" size="xs">
Undo
</Button>
</AlertAction>
<AlertClose />
</Alert>
</div>
)
}Dismissible#
Add <AlertClose /> to let people dismiss it. The alert collapses smoothly and the content below slides up. Dismiss it from the keyboard and focus moves to the next control, here “Reset”.
"use client"
import * as React from "react"
import { IconInfoCircle } from "@tabler/icons-react"
import {
Alert,
AlertClose,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
export function AlertDismissible() {
const [key, setKey] = React.useState(0)
return (
<div className="flex w-full max-w-lg flex-col gap-3">
<Alert key={key} variant="info">
<IconInfoCircle />
<AlertTitle>We’ve updated our terms</AlertTitle>
<AlertDescription>
Read the <a href="#">new terms</a> before your next billing date.
</AlertDescription>
<AlertClose />
</Alert>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setKey(key + 1)}>
Reset
</Button>
<Button variant="outline" size="sm">
Next focusable
</Button>
</div>
</div>
)
}Controlled#
Pass open and onOpenChange to drive it from your own state. It animates both in and out.
Content below slides up.
"use client"
import * as React from "react"
import { IconCircleCheck } from "@tabler/icons-react"
import {
Alert,
AlertClose,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
export function AlertControlled() {
const [open, setOpen] = React.useState(true)
return (
<div className="flex w-full max-w-lg flex-col gap-3">
<Button
variant="outline"
size="sm"
className="self-start"
onClick={() => setOpen(!open)}
>
{open ? "Hide" : "Show"} alert
</Button>
<Alert variant="success" open={open} onOpenChange={setOpen}>
<IconCircleCheck />
<AlertTitle>Deployment complete</AlertTitle>
<AlertDescription>
Your changes are live on production.
</AlertDescription>
<AlertClose />
</Alert>
<p className="text-sm text-muted-foreground">Content below slides up.</p>
</div>
)
}Cancel a dismiss#
Call event.preventDefault() in the close button’s onClick to keep the alert open, for example to ask for confirmation first.
"use client"
import * as React from "react"
import { IconAlertTriangle } from "@tabler/icons-react"
import {
Alert,
AlertClose,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert"
export function AlertCancelDismiss() {
const [confirmed, setConfirmed] = React.useState(false)
return (
<Alert variant="warning" className="max-w-lg">
<IconAlertTriangle />
<AlertTitle>Unsaved changes</AlertTitle>
<AlertDescription>
{confirmed
? "Click dismiss again to close."
: "The first click is cancelled with event.preventDefault()."}
</AlertDescription>
<AlertClose
onClick={(event) => {
if (!confirmed) {
event.preventDefault()
setConfirmed(true)
}
}}
/>
</Alert>
)
}Inserted after load#
Alerts that mount after the page has loaded fade and slide in, and screen readers announce them. Alerts already in the server HTML appear without motion.
"use client"
import * as React from "react"
import { IconCircleX } from "@tabler/icons-react"
import {
Alert,
AlertClose,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
export function AlertInserted() {
const [errors, setErrors] = React.useState<number[]>([])
return (
<div className="flex w-full max-w-lg flex-col gap-3">
<Button
variant="outline"
size="sm"
className="self-start"
onClick={() => setErrors([...errors, Date.now()])}
>
Submit form
</Button>
{errors.map((id) => (
<Alert
key={id}
variant="destructive"
onOpenChange={() =>
setErrors((current) => current.filter((error) => error !== id))
}
>
<IconCircleX />
<AlertTitle>Email is already in use</AlertTitle>
<AlertDescription>Try signing in instead.</AlertDescription>
<AlertClose />
</Alert>
))}
</div>
)
}Rich content#
Inline icons and links inside the description keep their own style. Use render to give the title a heading level.
Rendered as an h3
Press to open the terminal, or read the docs.
Open guideimport {
IconExternalLink,
IconInfoCircle,
IconTerminal2,
} from "@tabler/icons-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
export function AlertRichContent() {
return (
<Alert className="max-w-lg">
<IconInfoCircle />
<AlertTitle render={<h3 />}>Rendered as an h3</AlertTitle>
<AlertDescription>
<p>
Press <IconTerminal2 className="inline size-4 align-text-bottom" /> to
open the terminal, or read the <a href="#">docs</a>.
</p>
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<a href="#" />}
>
Open guide <IconExternalLink />
</Button>
</AlertDescription>
</Alert>
)
}Right to left#
The icon, content and close button mirror with the reading direction.
import { IconAlertTriangle } from "@tabler/icons-react"
import {
Alert,
AlertClose,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert"
export function AlertRtl() {
return (
<div dir="rtl" className="w-full max-w-lg">
<Alert variant="warning">
<IconAlertTriangle />
<AlertTitle>تنتهي الفترة التجريبية خلال 3 أيام</AlertTitle>
<AlertDescription>
أضف طريقة دفع لإبقاء مساحة العمل نشطة.
</AlertDescription>
<AlertClose />
</Alert>
</div>
)
}| Key | Action |
|---|---|
| Tab | Moves focus to links, actions and the close button inside the alert. |
| EnterSpace | Activates the focused close button. Once the alert has collapsed, focus moves to the next focusable element, or the previous one when nothing follows. |
destructiveandwarningalerts getrole="alert"and are announced right away. Every other variant getsrole="status"and waits for a pause.- The close button is labelled “Dismiss”. Pass
aria-labelto translate it. - Focus is never lost when a focused alert closes.
- With reduced motion on, alerts appear and disappear instantly.
Every part accepts the attributes of the element it renders. The variants are exported as alertVariants for styling other elements the same way.
| Prop | Type | Default |
|---|---|---|
variantColors the icon. destructive and warning are announced assertively. | "default" | "destructive" | "success" | "info" | "warning" | "default" |
appearance | "outline" | "soft" | "outline" |
open | boolean | – |
defaultOpen | boolean | true |
onOpenChangeCalled when the close button is pressed. | (open: boolean) => void | – |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="alert" | Target the alert in CSS. |
data-variant | The current variant. |
data-appearance | The current appearance. |
data-ending-style | Present while the alert collapses after a dismiss. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="alert-title" | Target titles in CSS. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="alert-description" | Target descriptions in CSS. Plain links inside are underlined for you. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="alert-action" | Target the action area in CSS. |
A ghost <Button /> that closes the nearest alert. It accepts every Button prop and must be used inside <Alert />.
| Prop | Type | Default |
|---|---|---|
aria-label | string | "Dismiss" |
children | ReactNode | <IconX /> |
onClickCall event.preventDefault() to keep the alert open. | (event: MouseEvent) => void | – |
| Attribute | Description |
|---|---|
data-slot="alert-close" | Target the close button in CSS. |