A window over the page for forms and focused tasks, with a pinned header and footer, nesting, and a swipeable bottom sheet on phones.
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export function DialogDemo() {
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
Edit profile
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>
Make changes to your profile here. Click save when you’re done.
</DialogDescription>
</DialogHeader>
<DialogBody>
<FieldGroup>
<Field>
<FieldLabel>Name</FieldLabel>
<Input defaultValue="Olivia Martin" autoComplete="name" />
</Field>
<Field>
<FieldLabel>Username</FieldLabel>
<Input defaultValue="@olivia" autoComplete="username" />
</Field>
</FieldGroup>
</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>
Cancel
</DialogClose>
<DialogClose render={<Button />}>Save changes</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/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/dialog.tsx "use client" import * as React from "react" import { Drawer as DialogPrimitive } 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, SheetClose, 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 Dialog<Payload>(props: DialogPrimitive.Root.Props<Payload>) { return <Sheet {...props} /> } function DialogTrigger<Payload>(props: DialogPrimitive.Trigger.Props<Payload>) { return <SheetTrigger data-slot="dialog-trigger" {...props} /> } function DialogPortal(props: DialogPrimitive.Portal.Props) { return <SheetPortal data-slot="dialog-portal" {...props} /> } function DialogOverlay(props: DialogPrimitive.Backdrop.Props) { return <SheetOverlay data-slot="dialog-overlay" {...props} /> } function DialogClose(props: DialogPrimitive.Close.Props) { return <SheetClose data-slot="dialog-close" {...props} /> } const DialogContentContext = React.createContext<{ showCloseButton: boolean }>({ showCloseButton: false }) const dialogContentVariants = cva( "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:duration-200 sm:ease-out-quint sm:[--bleed:0px] sm:[--sheet-close-radius:calc(var(--radius-xl)-0.75rem)] 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]>:last-child:not([data-slot=dialog-footer])]:pb-6", { variants: { size: { default: "sm:max-w-lg", sm: "sm:max-w-sm", lg: "sm:max-w-2xl", }, }, defaultVariants: { size: "default", }, } ) function DialogContent({ className, size = "default", showCloseButton = true, initialFocus, ref, ...props }: DialogPrimitive.Popup.Props & VariantProps<typeof dialogContentVariants> & { showCloseButton?: boolean }) { const popupRef = React.useRef<HTMLDivElement>(null) const resolvedSize = size ?? "default" const setRef = React.useMemo(() => mergeRefs(popupRef, ref), [ref]) const context = React.useMemo(() => ({ showCloseButton }), [showCloseButton]) return ( <DialogContentContext.Provider value={context}> <SheetContent side="bottom" showCloseButton={showCloseButton} ref={setRef} data-slot="dialog-content" data-size={resolvedSize} initialFocus={ initialFocus ?? ((openType) => (openType === "touch" ? popupRef.current : true)) } className={mergeClassName( dialogContentVariants({ size: resolvedSize }), className )} {...props} /> </DialogContentContext.Provider> ) } function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { const { showCloseButton } = React.useContext(DialogContentContext) return ( <div data-slot="dialog-header" className={cn( "flex min-w-0 flex-col gap-1.5 px-6 pt-6", showCloseButton && "pe-12", className )} {...props} /> ) } function DialogBody({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="dialog-body" className={cn( "-my-1 min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none px-6 py-1 wrap-anywhere", className )} {...props} /> ) } function DialogFooter({ className, showCloseButton = false, children, ...props }: React.ComponentProps<"div"> & { showCloseButton?: boolean }) { return ( <div data-slot="dialog-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} > {children} {showCloseButton ? ( <DialogClose render={<Button variant="outline" />}>Close</DialogClose> ) : null} </div> ) } function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { return ( <DialogPrimitive.Title data-slot="dialog-title" className={mergeClassName( "min-w-0 text-lg leading-snug font-semibold text-pretty wrap-anywhere", className )} {...props} /> ) } function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) { return ( <DialogPrimitive.Description data-slot="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} /> ) } const createDialogHandle = DialogPrimitive.createHandle export { Dialog, DialogTrigger, DialogPortal, DialogOverlay, DialogContent, DialogHeader, DialogBody, DialogFooter, DialogTitle, DialogDescription, DialogClose, dialogContentVariants, createDialogHandle, }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 {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"<Dialog>
<DialogTrigger render={<Button variant="outline" />}>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>Make changes to your profile.</DialogDescription>
</DialogHeader>
<DialogBody>{/* fields */}</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
<Button>Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>Dialog
├── DialogTrigger
└── DialogContent
├── DialogHeader
│ ├── DialogTitle
│ └── DialogDescription
├── DialogBody
└── DialogFooter
└── DialogCloseForm#
Keep the form in <DialogBody /> and point the submit button in the footer at it with form. Enter submits, and the dialog closes through onOpenChange once the value is saved.
Workspace: Acme
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export function DialogForm() {
const [open, setOpen] = React.useState(false)
const [name, setName] = React.useState("Acme")
const [draft, setDraft] = React.useState(name)
const formId = React.useId()
return (
<div className="flex flex-col items-center gap-3">
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
setDraft(name)
}
setOpen(nextOpen)
}}
>
<DialogTrigger render={<Button variant="outline" />}>
Rename workspace
</DialogTrigger>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>Rename workspace</DialogTitle>
<DialogDescription>
Everyone in the workspace will see the new name.
</DialogDescription>
</DialogHeader>
<DialogBody>
<form
id={formId}
onSubmit={(event) => {
event.preventDefault()
setName(draft.trim() || name)
setOpen(false)
}}
>
<FieldGroup>
<Field>
<FieldLabel>Workspace name</FieldLabel>
<Input
name="name"
value={draft}
onChange={(event) => setDraft(event.target.value)}
required
/>
</Field>
</FieldGroup>
</form>
</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>
Cancel
</DialogClose>
<Button type="submit" form={formId}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<p className="text-sm text-muted-foreground">Workspace: {name}</p>
</div>
)
}Custom close button#
Hide the corner button with showCloseButton={false} on the content and add a Close button to the footer with showCloseButton.
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export function DialogCustomCloseButton() {
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>Share</DialogTrigger>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Share link</DialogTitle>
<DialogDescription>
Anyone who has this link will be able to view this.
</DialogDescription>
</DialogHeader>
<DialogBody>
<Field>
<FieldLabel>Link</FieldLabel>
<Input
defaultValue="https://hextaui.com/docs/installation"
readOnly
/>
</Field>
</DialogBody>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
)
}No close button#
Without a button the dialog still closes with Esc, a click outside, or a swipe down on phones.
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
export function DialogNoCloseButton() {
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
No close button
</DialogTrigger>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>No close button</DialogTitle>
<DialogDescription>
Close it with Esc, by clicking outside, or by swiping down on a
phone.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
)
}Sizes#
size sets the maximum width on larger screens: sm, default or lg.
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
const sizes = [
{ size: "sm", label: "Small" },
{ size: "default", label: "Default" },
{ size: "lg", label: "Large" },
] as const
export function DialogSizes() {
return (
<div className="flex flex-wrap justify-center gap-2">
{sizes.map(({ size, label }) => (
<Dialog key={size}>
<DialogTrigger render={<Button variant="outline" />}>
{label}
</DialogTrigger>
<DialogContent size={size}>
<DialogHeader>
<DialogTitle>{label} dialog</DialogTitle>
<DialogDescription>
On phones every size becomes a full-width bottom sheet.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button />}>Done</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
))}
</div>
)
}Sticky footer#
Long content in <DialogBody /> scrolls while the header and footer stay in place, so the actions are always in reach.
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
export function DialogStickyFooter() {
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
Terms of service
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Terms of service</DialogTitle>
<DialogDescription>Last updated October 2026.</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="flex flex-col gap-3 text-sm text-muted-foreground">
{Array.from({ length: 20 }, (_, index) => (
<p key={index}>
{index + 1}. By using the service you agree to keep your account
secure and to use it in line with these terms and any laws that
apply to you.
</p>
))}
</div>
</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>
Decline
</DialogClose>
<DialogClose render={<Button />}>Accept</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Scrollable content#
Without a footer the body scrolls under the header and keeps its bottom padding.
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
export function DialogScrollableContent() {
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
Release notes
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>What’s new</DialogTitle>
<DialogDescription>Version 2.4.0</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="flex flex-col gap-3 text-sm text-muted-foreground">
{Array.from({ length: 24 }, (_, index) => (
<p key={index}>
{index + 1}. Improved performance of the dashboard charts and
fixed an issue where filters reset after navigation.
</p>
))}
</div>
</DialogBody>
</DialogContent>
</Dialog>
)
}Controlled#
Pass open and onOpenChange to open it from code, without a trigger.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
export function DialogControlled() {
const [open, setOpen] = React.useState(false)
return (
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => setOpen(true)}>
Open from state
</Button>
<span className="text-sm text-muted-foreground">
{open ? "Open" : "Closed"}
</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Controlled dialog</DialogTitle>
<DialogDescription>
The open state lives in your component. Focus returns to the
button that opened it.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button />}>Done</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}Nested#
A dialog or alert dialog opened from inside another stacks on top. The parent steps back and a lighter backdrop covers it. Esc closes only the top one.
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
export function DialogNested() {
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
Workspace settings
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Workspace settings</DialogTitle>
<DialogDescription>
Manage members and the danger zone.
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="flex flex-wrap gap-2">
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
Invite member
</DialogTrigger>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>Invite member</DialogTitle>
<DialogDescription>
The settings dialog steps back while this one is open.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button />}>Send invite</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog>
<AlertDialogTrigger render={<Button variant="destructive" />}>
Delete workspace
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete workspace?</AlertDialogTitle>
<AlertDialogDescription>
All projects in this workspace will be removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</DialogBody>
</DialogContent>
</Dialog>
)
}Detached triggers#
Create a handle with createDialogHandle() to share one dialog between many triggers. Each trigger passes a payload that the dialog reads through a render function.
"use client"
import { Button } from "@/components/ui/button"
import {
createDialogHandle,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
const people = createDialogHandle<{ name: string; role: string }>()
export function DialogDetachedTriggers() {
return (
<div className="flex flex-wrap justify-center gap-2">
<DialogTrigger
handle={people}
payload={{ name: "Ada Lovelace", role: "Owner" }}
render={<Button variant="outline" size="sm" />}
>
Ada
</DialogTrigger>
<DialogTrigger
handle={people}
payload={{ name: "Linus Torvalds", role: "Member" }}
render={<Button variant="outline" size="sm" />}
>
Linus
</DialogTrigger>
<Dialog handle={people}>
{({ payload }) => (
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>{payload?.name}</DialogTitle>
<DialogDescription>Role: {payload?.role}</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button />}>Done</DialogClose>
</DialogFooter>
</DialogContent>
)}
</Dialog>
</div>
)
}Right to left#
Pass dir to the content as well, because it renders in a portal outside your RTL container.
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export function DialogRtl() {
return (
<div dir="rtl">
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
تعديل الملف الشخصي
</DialogTrigger>
<DialogContent dir="rtl">
<DialogHeader>
<DialogTitle>تعديل الملف الشخصي</DialogTitle>
<DialogDescription>
قم بإجراء تغييرات على ملفك الشخصي هنا. انقر على حفظ عند الانتهاء.
</DialogDescription>
</DialogHeader>
<DialogBody>
<FieldGroup>
<Field>
<FieldLabel>الاسم</FieldLabel>
<Input defaultValue="ليلى أحمد" />
</Field>
<Field>
<FieldLabel>اسم المستخدم</FieldLabel>
<Input defaultValue="@layla" dir="ltr" />
</Field>
</FieldGroup>
</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>
إلغاء
</DialogClose>
<DialogClose render={<Button />}>حفظ التغييرات</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}| Key | Action |
|---|---|
| EnterSpace | On the trigger, opens the dialog and moves focus to its first control. |
| TabShift+Tab | Moves focus between controls. Focus stays inside. |
| Esc | Closes the top dialog and returns focus to its trigger. |
- The content has
role="dialog"and is labelled by its title and described by its description. Always include a<DialogTitle />. - With a mouse or keyboard, focus starts on the first control. With touch it starts on the dialog itself, so the on-screen keyboard doesn’t cover the content before people choose a field. Pass
initialFocusto change it. - The corner close button is labelled “Close”, and the page behind is inert and can’t scroll.
- On phones it becomes a bottom sheet that can be swiped down to close. With reduced motion on, it fades instead of scaling or sliding.
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 |
onOpenChange | (open: boolean, details) => void | – |
onOpenChangeCompleteCalled after the open or close animation. | (open: boolean) => void | – |
modal | boolean | "trap-focus" | true |
disablePointerDismissalKeeps the dialog open on clicks outside. | boolean | false |
handleConnects detached triggers. | DialogHandle<Payload> | – |
actionsRefClose or unmount the dialog imperatively. | RefObject<{ close, unmount }> | – |
children | ReactNode | ({ payload }) => ReactNode | – |
| Prop | Type | Default |
|---|---|---|
handle | DialogHandle<Payload> | – |
payloadPassed to the dialog’s render function. | Payload | – |
render | ReactElement | (props, state) => ReactElement | <button> |
| Attribute | Description |
|---|---|
data-slot="dialog-trigger" | Target triggers in CSS. |
data-popup-open | Present while its dialog is open. |
| Prop | Type | Default |
|---|---|---|
size | "sm" | "default" | "lg" | "default" |
showCloseButtonShows the close button in the corner. | boolean | true |
initialFocus | boolean | RefObject | (openType) => HTMLElement | boolean | First control, or the dialog on touch |
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="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 title and description. It leaves room for the close button.
| Attribute | Description |
|---|---|
data-slot="dialog-header" | Target the header in CSS. |
A <div> that scrolls when the content is taller than the screen, keeping the header and footer in place.
| Attribute | Description |
|---|---|
data-slot="dialog-body" | Target the body in CSS. |
A <div> for the actions. Buttons stack full width on phones, with the first one at the bottom.
| Prop | Type | Default |
|---|---|---|
showCloseButtonAdds an outline Close button after the children. | boolean | false |
| Attribute | Description |
|---|---|
data-slot="dialog-footer" | Target the footer in CSS. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <h2> |
| Attribute | Description |
|---|---|
data-slot="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="dialog-description" | Target the description in CSS. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <button> |
| Attribute | Description |
|---|---|
data-slot="dialog-close" | Target close buttons in CSS. |
<DialogContent /> 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 <DialogTrigger /> elements anywhere on the page to one <Dialog />. Type the payload with a generic: createDialogHandle<{ name: string }>().