A confirmation dialog for destructive or important actions that waits for async work, and becomes a bottom sheet on phones.
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
export function AlertDialogDemo() {
return (
<AlertDialog>
<AlertDialogTrigger render={<Button variant="outline" />}>
Show dialog
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Publish changes?</AlertDialogTitle>
<AlertDialogDescription>
Your changes will be visible to everyone with the link.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Publish</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/alert-dialog.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-dialog.tsx "use client" import * as React from "react" import { Drawer as AlertDialogPrimitive } from "@base-ui/react/drawer" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Button } from "@/components/ui/button" import { Sheet, SheetContent, SheetOverlay, SheetPortal, SheetTrigger, } from "@/components/ui/sheet" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]) { return (node: T | null) => { for (const ref of refs) { if (typeof ref === "function") { ref(node) } else if (ref) { ref.current = node } } } } function isThenable(value: unknown): value is PromiseLike<unknown> { return ( typeof value === "object" && value !== null && typeof (value as PromiseLike<unknown>).then === "function" ) } type AlertDialogContextValue = { pendingActionId: string | null close: () => void runAction: (id: string, action: PromiseLike<unknown>) => void } const AlertDialogContext = React.createContext<AlertDialogContextValue | null>( null ) function useAlertDialogContext(part: string) { const context = React.useContext(AlertDialogContext) if (!context) { throw new Error(`<${part}> must be used within <AlertDialog>.`) } return context } function AlertDialog<Payload>({ actionsRef, onOpenChange, onOpenChangeComplete, disablePointerDismissal = true, ...props }: AlertDialogPrimitive.Root.Props<Payload>) { const [pendingActionId, setPendingActionId] = React.useState<string | null>( null ) const pendingRef = React.useRef(false) const localActionsRef = React.useRef<AlertDialogPrimitive.Root.Actions>(null) React.useImperativeHandle(actionsRef, () => ({ close: () => localActionsRef.current?.close(), unmount: () => localActionsRef.current?.unmount(), })) const context = React.useMemo<AlertDialogContextValue>( () => ({ pendingActionId, close: () => localActionsRef.current?.close(), runAction: (id, action) => { pendingRef.current = true setPendingActionId(id) Promise.resolve(action).then( () => { pendingRef.current = false localActionsRef.current?.close() }, () => { pendingRef.current = false setPendingActionId(null) } ) }, }), [pendingActionId] ) return ( <AlertDialogContext.Provider value={context}> <Sheet disablePointerDismissal={disablePointerDismissal} actionsRef={localActionsRef} onOpenChange={(open, details) => { if (!open && pendingRef.current) { details.cancel() return } if (open) { setPendingActionId(null) } onOpenChange?.(open, details) }} onOpenChangeComplete={(open) => { if (!open) { setPendingActionId(null) } onOpenChangeComplete?.(open) }} {...props} /> </AlertDialogContext.Provider> ) } function AlertDialogTrigger<Payload>( props: AlertDialogPrimitive.Trigger.Props<Payload> ) { return <SheetTrigger data-slot="alert-dialog-trigger" {...props} /> } function AlertDialogPortal(props: AlertDialogPrimitive.Portal.Props) { return <SheetPortal data-slot="alert-dialog-portal" {...props} /> } function AlertDialogOverlay(props: AlertDialogPrimitive.Backdrop.Props) { return <SheetOverlay data-slot="alert-dialog-overlay" {...props} /> } type AlertDialogSize = "default" | "sm" const AlertDialogSizeContext = React.createContext<AlertDialogSize>("default") const alertDialogContentVariants = cva( "p-6 pb-[calc(1.5rem+var(--bleed)+env(safe-area-inset-bottom))] data-nested-drawer-open:scale-[calc(1-0.04*var(--nested-drawers))] max-sm:origin-bottom sm:inset-x-auto sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:w-[calc(100%-2rem)] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:pb-6 sm:duration-200 sm:ease-out-quint sm:[--bleed:0px] sm:data-ending-style:opacity-0 sm:data-ending-style:duration-150 sm:data-nested-drawer-open:top-[calc(50%-0.5rem*var(--nested-drawers))] sm:data-starting-style:opacity-0 sm:motion-safe:data-ending-style:-translate-y-1/2 sm:motion-safe:data-ending-style:scale-96 sm:motion-safe:data-starting-style:-translate-y-1/2 sm:motion-safe:data-starting-style:scale-96 sm:[&>[data-slot=sheet-handle]]:hidden [&>[data-slot=sheet-inner]]:gap-6", { variants: { size: { default: "sm:max-w-md", sm: "sm:max-w-sm", }, }, defaultVariants: { size: "default", }, } ) function AlertDialogContent({ className, size = "default", initialFocus, ref, onPointerDown, onTouchStart, ...props }: AlertDialogPrimitive.Popup.Props & VariantProps<typeof alertDialogContentVariants>) { const pending = React.useContext(AlertDialogContext)?.pendingActionId != null const popupRef = React.useRef<HTMLDivElement>(null) const resolvedSize = size ?? "default" const setRef = React.useMemo(() => mergeRefs(popupRef, ref), [ref]) return ( <AlertDialogSizeContext.Provider value={resolvedSize}> <SheetContent side="bottom" showCloseButton={false} role="alertdialog" ref={setRef} onPointerDown={(event) => { onPointerDown?.(event) if (pending) { event.stopPropagation() } }} onTouchStart={(event) => { onTouchStart?.(event) if (pending) { event.stopPropagation() } }} data-slot="alert-dialog-content" data-size={resolvedSize} initialFocus={ initialFocus ?? (() => popupRef.current?.querySelector<HTMLElement>( "[data-slot=alert-dialog-cancel]" ) ?? true) } className={mergeClassName( alertDialogContentVariants({ size: resolvedSize }), className )} {...props} /> </AlertDialogSizeContext.Provider> ) } const alertDialogHeaderVariants = cva( "grid justify-items-center gap-1.5 text-center", { variants: { size: { default: "sm:justify-items-start sm:text-start sm:has-[>[data-slot=alert-dialog-media]]:grid-cols-[auto_1fr] sm:has-[>[data-slot=alert-dialog-media]]:gap-x-4", sm: "", }, }, defaultVariants: { size: "default", }, } ) function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { const size = React.useContext(AlertDialogSizeContext) return ( <div data-slot="alert-dialog-header" className={cn(alertDialogHeaderVariants({ size }), className)} {...props} /> ) } const alertDialogFooterVariants = cva("gap-2", { variants: { size: { default: "flex flex-col-reverse sm:flex-row sm:justify-end max-sm:[&>*:not([data-slot=button-status])]:h-10 max-sm:[&>*:not([data-slot=button-status])]:w-full", sm: "grid grid-cols-2 max-sm:[&>*:not([data-slot=button-status])]:h-10", }, }, defaultVariants: { size: "default", }, }) const footerArrowKeys = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"] function focusAdjacentFooterButton(event: React.KeyboardEvent<HTMLDivElement>) { const footer = event.currentTarget const current = event.target as HTMLElement if ( !footerArrowKeys.includes(event.key) || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ) { return } const buttons = Array.from( footer.querySelectorAll<HTMLElement>("button, a[href]") ).filter( (button) => button.closest("[data-slot=alert-dialog-footer]") === footer && !button.hasAttribute("disabled") ) if (!buttons.includes(current)) { return } const rtl = getComputedStyle(footer).direction === "rtl" const ordered = buttons .map((button) => ({ button, rect: button.getBoundingClientRect() })) .sort((a, b) => Math.abs(a.rect.top - b.rect.top) > 1 ? a.rect.top - b.rect.top : rtl ? b.rect.left - a.rect.left : a.rect.left - b.rect.left ) .map(({ button }) => button) const forward = event.key === "ArrowDown" || event.key === (rtl ? "ArrowLeft" : "ArrowRight") const index = ordered.indexOf(current) const next = ordered[(index + (forward ? 1 : -1) + ordered.length) % ordered.length] event.preventDefault() next.focus() } function AlertDialogFooter({ className, onKeyDown, ...props }: React.ComponentProps<"div">) { const size = React.useContext(AlertDialogSizeContext) return ( <div data-slot="alert-dialog-footer" className={cn(alertDialogFooterVariants({ size }), className)} onKeyDown={(event) => { onKeyDown?.(event) if (!event.defaultPrevented) { focusAdjacentFooterButton(event) } }} {...props} /> ) } const alertDialogMediaVariants = cva( "mb-2 flex size-10 shrink-0 items-center justify-center rounded-lg sm:row-span-2 sm:mb-0 [&>svg:not([class*='size-'])]:size-5", { variants: { variant: { default: "bg-muted text-foreground", destructive: "bg-destructive/10 text-destructive", }, }, defaultVariants: { variant: "default", }, } ) function AlertDialogMedia({ className, variant = "default", ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertDialogMediaVariants>) { return ( <div data-slot="alert-dialog-media" className={cn(alertDialogMediaVariants({ variant }), className)} {...props} /> ) } function AlertDialogTitle({ className, ...props }: AlertDialogPrimitive.Title.Props) { const size = React.useContext(AlertDialogSizeContext) return ( <AlertDialogPrimitive.Title data-slot="alert-dialog-title" className={mergeClassName( cn( "min-w-0 font-semibold text-pretty wrap-anywhere", size === "sm" ? "text-base" : "text-lg leading-snug" ), className )} {...props} /> ) } function AlertDialogDescription({ className, ...props }: AlertDialogPrimitive.Description.Props) { return ( <AlertDialogPrimitive.Description data-slot="alert-dialog-description" className={mergeClassName( "min-w-0 text-sm 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])]:hover:decoration-current [&>p:not(:last-child)]:mb-2", className )} {...props} /> ) } type ButtonProps = React.ComponentProps<typeof Button> type AlertDialogActionProps = Omit<ButtonProps, "className" | "onClick"> & { className?: string onClick?: ( event: Parameters<NonNullable<ButtonProps["onClick"]>>[0] ) => void | PromiseLike<unknown> } function AlertDialogAction({ className, children, onClick, disabled, ...props }: AlertDialogActionProps) { const { pendingActionId, close, runAction } = useAlertDialogContext("AlertDialogAction") const id = React.useId() const loading = pendingActionId === id return ( <Button data-slot="alert-dialog-action" focusableWhenDisabled loading={loading} disabled={disabled || (pendingActionId !== null && !loading)} className={className} onClick={(event) => { const result = onClick?.(event) if (isThenable(result)) { runAction(id, result) } else if (!event.defaultPrevented) { close() } }} {...props} > {children} </Button> ) } type AlertDialogCancelProps = AlertDialogPrimitive.Close.Props & Pick<ButtonProps, "variant" | "size"> function AlertDialogCancel({ className, variant = "outline", size, disabled, ...props }: AlertDialogCancelProps) { const { pendingActionId } = useAlertDialogContext("AlertDialogCancel") return ( <AlertDialogPrimitive.Close data-slot="alert-dialog-cancel" disabled={disabled || pendingActionId !== null} render={<Button variant={variant} size={size} focusableWhenDisabled />} className={mergeClassName("data-disabled:opacity-50", className)} {...props} /> ) } const createAlertDialogHandle = AlertDialogPrimitive.createHandle export { AlertDialog, AlertDialogTrigger, AlertDialogPortal, AlertDialogOverlay, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogMedia, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel, alertDialogContentVariants, createAlertDialogHandle, }components/ui/sheet.tsx "use client" import * as React from "react" import { Drawer as SheetPrimitive } from "@base-ui/react/drawer" import { IconX } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Button } from "@/components/ui/button" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } const DialogLayerContext = React.createContext(0) function DialogLayerProvider({ children }: { children: React.ReactNode }) { const depth = React.useContext(DialogLayerContext) return ( <DialogLayerContext.Provider value={depth + 1}> {children} </DialogLayerContext.Provider> ) } function useDialogLayer() { return React.useContext(DialogLayerContext) } type SwipeDirection = "up" | "down" | "left" | "right" const SheetRootContext = React.createContext< ((direction: SwipeDirection) => void) | null >(null) function Sheet<Payload>({ swipeDirection, ...props }: SheetPrimitive.Root.Props<Payload>) { const [contentDirection, setContentDirection] = React.useState<SwipeDirection>("right") return ( <DialogLayerProvider> <SheetRootContext.Provider value={setContentDirection}> <SheetPrimitive.Root data-slot="sheet" swipeDirection={swipeDirection ?? contentDirection} {...props} /> </SheetRootContext.Provider> </DialogLayerProvider> ) } function SheetTrigger<Payload>(props: SheetPrimitive.Trigger.Props<Payload>) { return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> } function SheetPortal(props: SheetPrimitive.Portal.Props) { return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> } function SheetClose(props: SheetPrimitive.Close.Props) { return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> } const sheetOverlayVariants = cva( "fixed inset-0 z-50 min-h-dvh opacity-[calc(1-var(--drawer-swipe-progress,0))] transition-opacity duration-300 ease-drawer data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength,0.7)*300ms)] data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs supports-[-webkit-touch-callout:none]:absolute sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:duration-200 sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:ease-out-quint sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:data-ending-style:duration-150 [&[data-swiping]:not([data-ending-style])]:duration-0", { variants: { nested: { false: "bg-black/40 dark:bg-black/60", true: "bg-black/20 dark:bg-black/30", }, }, defaultVariants: { nested: false, }, } ) function SheetOverlay({ className, forceRender, ...props }: SheetPrimitive.Backdrop.Props) { const nested = useDialogLayer() > 1 return ( <SheetPrimitive.Backdrop data-slot="sheet-overlay" data-nested={nested ? "" : undefined} forceRender={forceRender ?? nested} className={mergeClassName(sheetOverlayVariants({ nested }), className)} {...props} /> ) } const sheetContentVariants = cva( "fixed z-50 flex flex-col gap-4 overflow-y-auto overscroll-none bg-popover text-popover-foreground shadow-lg ring-(length:--hairline) ring-foreground/10 transition-[transform,translate,scale,opacity,top] duration-300 ease-drawer outline-none [--bleed:3rem] focus-visible:outline-hidden data-ending-style:duration-[calc(var(--drawer-swipe-strength,0.7)*300ms)] data-nested-dialog-open:scale-[calc(1-0.02*var(--nested-dialogs))] data-nested-drawer-open:scale-[calc(1-0.02*var(--nested-drawers))] data-swiping:cursor-grabbing data-swiping:select-none motion-reduce:transition-opacity motion-reduce:data-ending-style:opacity-0 motion-reduce:data-starting-style:opacity-0 rtl:[--sheet-dir:-1] forced-colors:border [&[data-swiping]:not([data-ending-style])]:duration-0", { variants: { side: { top: "inset-x-0 top-[calc(var(--bleed)*-1)] max-h-[calc(100dvh-2rem+var(--bleed))] [transform:translateY(var(--drawer-swipe-movement-y,0px))] rounded-b-2xl pt-(--bleed) [--sheet-close-top:calc(0.75rem+var(--bleed))] motion-safe:data-ending-style:translate-y-[calc(-100%+var(--bleed))] motion-safe:data-starting-style:translate-y-[calc(-100%+var(--bleed))]", right: "inset-y-0 end-[calc(var(--bleed)*-1)] h-full w-[calc(75%+var(--bleed))] [transform:translateX(var(--drawer-swipe-movement-x,0px))] rounded-s-2xl pe-(--bleed) [--sheet-close-end:calc(0.75rem+var(--bleed))] motion-safe:data-ending-style:translate-x-[calc(var(--sheet-dir,1)*(100%-var(--bleed)))] motion-safe:data-starting-style:translate-x-[calc(var(--sheet-dir,1)*(100%-var(--bleed)))] sm:max-w-[calc(24rem+var(--bleed))]", bottom: "inset-x-0 bottom-[calc(var(--bleed)*-1)] max-h-[calc(100dvh-2rem+var(--bleed))] [transform:translateY(var(--drawer-swipe-movement-y,0px))] rounded-t-2xl pb-[calc(var(--bleed)+env(safe-area-inset-bottom))] [--sheet-close-radius:calc(var(--radius-2xl)-0.75rem)] motion-safe:data-ending-style:translate-y-[calc(100%-var(--bleed))] motion-safe:data-starting-style:translate-y-[calc(100%-var(--bleed))]", left: "inset-y-0 start-[calc(var(--bleed)*-1)] h-full w-[calc(75%+var(--bleed))] [transform:translateX(var(--drawer-swipe-movement-x,0px))] rounded-e-2xl ps-(--bleed) [--sheet-close-radius:calc(var(--radius-2xl)-0.75rem)] motion-safe:data-ending-style:translate-x-[calc(var(--sheet-dir,1)*(var(--bleed)-100%))] motion-safe:data-starting-style:translate-x-[calc(var(--sheet-dir,1)*(var(--bleed)-100%))] sm:max-w-[calc(24rem+var(--bleed))]", }, }, defaultVariants: { side: "right", }, } ) const sheetHandleVariants = cva( "group/sheet-handle absolute z-10 flex cursor-grab touch-none items-center justify-center active:cursor-grabbing", { variants: { side: { top: "inset-x-0 bottom-0 h-6", right: "inset-y-0 start-0 w-6", bottom: "inset-x-0 top-0 h-6", left: "inset-y-0 end-0 w-6", }, }, } ) const sheetHandleBarVariants = cva( "rounded-full bg-muted-foreground/30 transition-colors duration-150 group-hover/sheet-handle:bg-muted-foreground/50 group-active/sheet-handle:bg-muted-foreground/60", { variants: { side: { top: "h-1 w-10", right: "h-10 w-1", bottom: "h-1 w-10", left: "h-10 w-1", }, }, } ) type SheetSide = NonNullable<VariantProps<typeof sheetContentVariants>["side"]> const SheetContentContext = React.createContext<{ showCloseButton: boolean }>({ showCloseButton: false }) function resolveSwipeDirection(side: SheetSide, rtl: boolean): SwipeDirection { if (side === "top") { return "up" } if (side === "bottom") { return "down" } return (side === "right") !== rtl ? "right" : "left" } function SheetContent({ className, children, side = "right", showCloseButton = true, dir, ...props }: SheetPrimitive.Popup.Props & VariantProps<typeof sheetContentVariants> & { showCloseButton?: boolean }) { const resolvedSide = side ?? "right" const setSwipeDirection = React.useContext(SheetRootContext) const context = React.useMemo(() => ({ showCloseButton }), [showCloseButton]) React.useEffect(() => { const rtl = (dir ?? getComputedStyle(document.documentElement).direction) === "rtl" setSwipeDirection?.(resolveSwipeDirection(resolvedSide, rtl)) }, [dir, resolvedSide, setSwipeDirection]) return ( <SheetContentContext.Provider value={context}> <SheetPortal> <SheetOverlay /> <SheetPrimitive.Viewport data-slot="sheet-viewport" dir={dir} className="fixed inset-0 z-50" > <SheetPrimitive.Popup data-slot="sheet-content" data-side={resolvedSide} dir={dir} className={mergeClassName( sheetContentVariants({ side: resolvedSide }), className )} {...props} > <div data-slot="sheet-handle" aria-hidden="true" className={sheetHandleVariants({ side: resolvedSide })} > <div className={sheetHandleBarVariants({ side: resolvedSide })} /> </div> <SheetPrimitive.Content data-slot="sheet-inner" className="flex min-h-0 flex-1 flex-col gap-4" > {children} </SheetPrimitive.Content> {showCloseButton ? ( <SheetPrimitive.Close data-slot="sheet-close-button" aria-label="Close" render={<Button variant="ghost" size="icon-sm" />} className="absolute end-(--sheet-close-end,0.75rem) top-(--sheet-close-top,0.75rem) z-20 rounded-(--sheet-close-radius,var(--radius-md)) text-muted-foreground hover:text-foreground" > <IconX /> </SheetPrimitive.Close> ) : null} </SheetPrimitive.Popup> </SheetPrimitive.Viewport> </SheetPortal> </SheetContentContext.Provider> ) } function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { const { showCloseButton } = React.useContext(SheetContentContext) return ( <div data-slot="sheet-header" className={cn( "flex flex-col gap-1.5 px-6 pt-6", showCloseButton && "pe-12", className )} {...props} /> ) } function SheetBody({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sheet-body" className={cn( "min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none px-6 wrap-anywhere", className )} {...props} /> ) } function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sheet-footer" className={cn( "mt-auto flex flex-col-reverse gap-2 px-6 pb-6 sm:flex-row sm:justify-end max-sm:[&>*:not([data-slot=button-status])]:h-10", className )} {...props} /> ) } function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { return ( <SheetPrimitive.Title data-slot="sheet-title" className={mergeClassName( "min-w-0 text-lg leading-snug font-semibold text-pretty wrap-anywhere", className )} {...props} /> ) } function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) { return ( <SheetPrimitive.Description data-slot="sheet-description" className={mergeClassName( "min-w-0 text-sm text-pretty wrap-anywhere text-muted-foreground", className )} {...props} /> ) } const createSheetHandle = SheetPrimitive.createHandle export { Sheet, SheetTrigger, SheetPortal, SheetOverlay, SheetContent, SheetHeader, SheetBody, SheetFooter, SheetTitle, SheetDescription, SheetClose, sheetContentVariants, sheetOverlayVariants, createSheetHandle, DialogLayerProvider, useDialogLayer, }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 } - 4
Update the import paths to match your project setup.
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"<AlertDialog>
<AlertDialogTrigger render={<Button variant="outline" />}>
Delete
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this project?</AlertDialogTitle>
<AlertDialogDescription>This can’t be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>AlertDialog
├── AlertDialogTrigger
└── AlertDialogContent
├── AlertDialogHeader
│ ├── AlertDialogMedia
│ ├── AlertDialogTitle
│ └── AlertDialogDescription
└── AlertDialogFooter
├── AlertDialogCancel
└── AlertDialogActionAsync action#
Return a promise from the onClick of <AlertDialogAction />. The button shows a spinner and the dialog can’t be closed until the promise settles. It closes when it resolves and stays open when it rejects, so people can try again.
"use client"
import * as React from "react"
import { IconTrash } from "@tabler/icons-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function AlertDialogAsyncAction() {
const [shouldFail, setShouldFail] = React.useState(false)
const [attempts, setAttempts] = React.useState(0)
return (
<div className="flex flex-wrap items-center gap-2">
<AlertDialog onOpenChange={(open) => open && setAttempts(0)}>
<AlertDialogTrigger render={<Button variant="destructive" />}>
Delete project
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia variant="destructive">
<IconTrash />
</AlertDialogMedia>
<AlertDialogTitle>Delete “Acme website”?</AlertDialogTitle>
<AlertDialogDescription>
{attempts > 0
? `Deleting failed ${attempts} time${attempts > 1 ? "s" : ""}. Try again.`
: "All deployments, domains and environment variables will be removed. This can’t be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={async () => {
await wait(1500)
if (shouldFail) {
setAttempts((count) => count + 1)
throw new Error("Request failed")
}
}}
>
Delete project
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
variant="ghost"
size="sm"
aria-pressed={shouldFail}
onClick={() => setShouldFail(!shouldFail)}
>
Request will {shouldFail ? "fail" : "succeed"}
</Button>
</div>
)
}Media#
<AlertDialogMedia /> places an icon beside the title on desktop and above it on phones. Use variant="destructive" for destructive actions.
import { IconTrash } from "@tabler/icons-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
export function AlertDialogWithMedia() {
return (
<AlertDialog>
<AlertDialogTrigger render={<Button variant="outline" />}>
Delete file
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia variant="destructive">
<IconTrash />
</AlertDialogMedia>
<AlertDialogTitle>Delete “invoice-2026.pdf”?</AlertDialogTitle>
<AlertDialogDescription>
The file moves to the trash and is removed after 30 days.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}Small#
size="sm" centers the content and lays the buttons out side by side, for short questions.
import { IconLogout } from "@tabler/icons-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
export function AlertDialogSmall() {
return (
<AlertDialog>
<AlertDialogTrigger render={<Button variant="outline" />}>
Sign out
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogMedia>
<IconLogout />
</AlertDialogMedia>
<AlertDialogTitle>Sign out?</AlertDialogTitle>
<AlertDialogDescription>
You’ll need to sign in again on this device.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Sign out</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}Detached triggers#
Create a handle with createAlertDialogHandle() to share one dialog between many triggers. Each trigger passes its own payload, which the dialog reads through a render function.
"use client"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
createAlertDialogHandle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
const removeMember = createAlertDialogHandle<{ name: string }>()
export function AlertDialogDetachedTriggers() {
return (
<div className="flex flex-wrap gap-2">
{["Olivia", "Liam"].map((name) => (
<AlertDialogTrigger
key={name}
handle={removeMember}
payload={{ name }}
render={<Button variant="outline" />}
>
Remove {name}
</AlertDialogTrigger>
))}
<AlertDialog handle={removeMember}>
{({ payload }) => (
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Remove {payload?.name}?</AlertDialogTitle>
<AlertDialogDescription>
They’ll lose access to this workspace.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive">
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
)}
</AlertDialog>
</div>
)
}Controlled#
Pass open and onOpenChange to open it from code, without a trigger. Focus still lands on Cancel.
"use client"
import * as React from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
export function AlertDialogControlled() {
const [open, setOpen] = React.useState(false)
return (
<>
<Button
variant="outline"
onClick={() => window.setTimeout(() => setOpen(true), 600)}
>
Open in 600ms
</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Session expiring</AlertDialogTitle>
<AlertDialogDescription>
You’ve been inactive for a while. Stay signed in?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Sign out</AlertDialogCancel>
<AlertDialogAction>Stay signed in</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}Nested#
A dialog opened from inside another stacks on top. The parent scales back while the child is open and comes forward again when it closes.
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
export function AlertDialogNested() {
return (
<AlertDialog>
<AlertDialogTrigger render={<Button variant="outline" />}>
Reset settings
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset all settings?</AlertDialogTitle>
<AlertDialogDescription>
Theme, notifications and shortcuts go back to their defaults.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialog>
<AlertDialogTrigger render={<Button variant="destructive" />}>
Reset
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Are you really sure?</AlertDialogTitle>
<AlertDialogDescription>
The parent dialog steps back while this one is open.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Go back</AlertDialogCancel>
<AlertDialogAction variant="destructive">
Reset
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}Long content#
Content taller than the screen scrolls inside the dialog while the page stays locked.
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
const sections = Array.from({ length: 12 }, (_, index) => index + 1)
export function AlertDialogLongContent() {
return (
<AlertDialog>
<AlertDialogTrigger render={<Button variant="outline" />}>
Review terms
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Accept the updated terms?</AlertDialogTitle>
<AlertDialogDescription render={<div />}>
{sections.map((section) => (
<p key={section}>
Section {section}. We may update these terms from time to time.
Continued use of the service after changes take effect means you
accept the new terms. Read the <a href="#">full policy</a> for
details.
</p>
))}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Decline</AlertDialogCancel>
<AlertDialogAction>Accept</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}Right to left#
Pass dir to the content as well, because it renders in a portal outside your RTL container. Arrow keys in the footer follow the reading direction.
import { IconTrash } from "@tabler/icons-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
export function AlertDialogRtl() {
return (
<div dir="rtl">
<AlertDialog>
<AlertDialogTrigger render={<Button variant="outline" />}>
حذف الملف
</AlertDialogTrigger>
<AlertDialogContent dir="rtl">
<AlertDialogHeader>
<AlertDialogMedia variant="destructive">
<IconTrash />
</AlertDialogMedia>
<AlertDialogTitle>حذف هذا الملف؟</AlertDialogTitle>
<AlertDialogDescription>
لا يمكن التراجع عن هذا الإجراء.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>إلغاء</AlertDialogCancel>
<AlertDialogAction variant="destructive">حذف</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}| Key | Action |
|---|---|
| EnterSpace | On the trigger, opens the dialog and moves focus to Cancel. |
| TabShift+Tab | Moves focus between controls. Focus stays inside. |
| ←→↑↓ | Moves between the footer buttons and wraps around. Left and right follow the reading direction. |
| Esc | Closes the dialog and returns focus to the trigger. Ignored while an async action is running. |
- The content has
role="alertdialog"and is labelled by its title and described by its description. - Focus starts on
<AlertDialogCancel />, the least destructive choice. PassinitialFocusto change it. - Clicking the backdrop doesn’t close it, so a decision is never dismissed by accident. Set
disablePointerDismissal={false}to allow it. - On phones it becomes a bottom sheet that can be swiped down to cancel. While an action is pending, swiping, Esc and Cancel are blocked and the buttons stay focusable.
- With reduced motion on, it fades instead of scaling.
Built on the Base UI drawer, through <Sheet />. Every part accepts the props of the primitive or element it wraps.
| Prop | Type | Default |
|---|---|---|
open | boolean | – |
defaultOpen | boolean | false |
onOpenChangeNot called for closes blocked by a pending action. | (open: boolean, details) => void | – |
onOpenChangeCompleteCalled after the open or close animation. | (open: boolean) => void | – |
disablePointerDismissalKeeps the dialog open on backdrop clicks. | boolean | true |
handleConnects detached triggers. | AlertDialogHandle<Payload> | – |
actionsRefClose or unmount the dialog imperatively. | RefObject<{ close, unmount }> | – |
children | ReactNode | ({ payload }) => ReactNode | – |
| Prop | Type | Default |
|---|---|---|
handle | AlertDialogHandle<Payload> | – |
payloadPassed to the dialog’s render function. | Payload | – |
render | ReactElement | (props, state) => ReactElement | <button> |
| Attribute | Description |
|---|---|
data-slot="alert-dialog-trigger" | Target triggers in CSS. |
data-popup-open | Present while its dialog is open. |
| Prop | Type | Default |
|---|---|---|
size | "default" | "sm" | "default" |
initialFocus | boolean | RefObject | (openType) => HTMLElement | boolean | Cancel button |
finalFocus | boolean | RefObject | (closeType) => HTMLElement | boolean | The trigger |
dirSet it when the dialog should be right to left. | "ltr" | "rtl" | – |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="alert-dialog-content" | Target the dialog in CSS. |
data-size | The current size. |
data-open | Present while open. |
data-starting-style | Present while the dialog animates in. |
data-ending-style | Present while the dialog animates out. |
data-nested-drawer-open | Present while a nested dialog is open on top. |
data-swiping | Present while it is being swiped on a phone. |
--nested-drawers | How many dialogs are open on top of this one. |
A <div> that stacks the media, title and description.
| Attribute | Description |
|---|---|
data-slot="alert-dialog-header" | Target the header in CSS. |
| Prop | Type | Default |
|---|---|---|
variant | "default" | "destructive" | "default" |
| Attribute | Description |
|---|---|
data-slot="alert-dialog-media" | Target the media in CSS. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <h2> |
| Attribute | Description |
|---|---|
data-slot="alert-dialog-title" | Target the title in CSS. |
| Prop | Type | Default |
|---|---|---|
renderUse render={<div />} when it holds several paragraphs. | ReactElement | (props, state) => ReactElement | <p> |
| Attribute | Description |
|---|---|
data-slot="alert-dialog-description" | Target the description in CSS. |
A <div> for the buttons. Buttons stack full width on phones, and arrow keys move between them.
| Attribute | Description |
|---|---|
data-slot="alert-dialog-footer" | Target the footer in CSS. |
A <Button /> that closes the dialog when clicked. It accepts every Button prop.
| Prop | Type | Default |
|---|---|---|
onClickReturn a promise to show a spinner and keep the dialog open until it settles. Call event.preventDefault() to keep it open. | (event) => void | PromiseLike<unknown> | – |
variant | ButtonVariant | "default" |
| Attribute | Description |
|---|---|
data-slot="alert-dialog-action" | Target actions in CSS. |
| Prop | Type | Default |
|---|---|---|
variant | ButtonVariant | "outline" |
size | ButtonSize | – |
disabledAlso disabled while an action is pending. | boolean | – |
| Attribute | Description |
|---|---|
data-slot="alert-dialog-cancel" | Target the cancel button in CSS. It receives initial focus. |
data-disabled | Present while disabled. |
<AlertDialogContent /> already renders both. Use them only when composing a custom popup.
| Prop | Type | Default |
|---|---|---|
keepMountedOn the portal, keeps the dialog in the DOM while closed. | boolean | false |
Returns a handle that connects <AlertDialogTrigger /> elements anywhere on the page to one <AlertDialog />. Type the payload with a generic: createAlertDialogHandle<{ name: string }>().