# Dialog

> A window over the page for forms and focused tasks, with a pinned header and footer, nesting, and a swipeable bottom sheet on phones.

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

```tsx title="components/examples/dialog/demo.tsx"
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>
  )
}
```

## Installation

### CLI

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

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

### Manual

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

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

Copy and paste the following code into your project.

```tsx title="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,
}
```

```tsx title="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,
}
```

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

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

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

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

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

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

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

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

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

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

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

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

  return { leaving, transitionKey }
}

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

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

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

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

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

  return { contentRef, layerRef }
}

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

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

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

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

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

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

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

  if (!enabled) {
    return button
  }

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

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

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

Update the import paths to match your project setup.

## Usage

```tsx
import {
  Dialog,
  DialogBody,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
```

```tsx
<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>
```

## Composition

```text
Dialog
├── DialogTrigger
└── DialogContent
    ├── DialogHeader
    │   ├── DialogTitle
    │   └── DialogDescription
    ├── DialogBody
    └── DialogFooter
        └── DialogClose
```

## Examples

### Form

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.

```tsx title="components/examples/dialog/form.tsx"
"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`.

```tsx title="components/examples/dialog/custom-close-button.tsx"
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.

```tsx title="components/examples/dialog/no-close-button.tsx"
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`.

```tsx title="components/examples/dialog/sizes.tsx"
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.

```tsx title="components/examples/dialog/sticky-footer.tsx"
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.

```tsx title="components/examples/dialog/scrollable-content.tsx"
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.

```tsx title="components/examples/dialog/controlled.tsx"
"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.

```tsx title="components/examples/dialog/nested.tsx"
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.

```tsx title="components/examples/dialog/detached-triggers.tsx"
"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.

```tsx title="components/examples/dialog/rtl.tsx"
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>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `Enter` `Space` | On the trigger, opens the dialog and moves focus to its first control. |
| `Tab` `Shift+Tab` | Moves focus between controls. Focus stays inside. |
| `Esc` | Closes the top dialog and returns focus to its trigger. |

## Accessibility

- 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 `initialFocus` to 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.

## API reference

Built on the Base UI drawer, through `<Sheet />`. Every part accepts the props of the primitive or element it wraps.

### Dialog

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` | – |  |
| `defaultOpen` | `boolean` | `false` |  |
| `onOpenChange` | `(open: boolean, details) => void` | – |  |
| `onOpenChangeComplete` | `(open: boolean) => void` | – | Called after the open or close animation. |
| `modal` | `boolean \| "trap-focus"` | `true` |  |
| `disablePointerDismissal` | `boolean` | `false` | Keeps the dialog open on clicks outside. |
| `handle` | `DialogHandle<Payload>` | – | Connects detached triggers. |
| `actionsRef` | `RefObject<{ close, unmount }>` | – | Close or unmount the dialog imperatively. |
| `children` | `ReactNode \| ({ payload }) => ReactNode` | – |  |

### DialogTrigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `handle` | `DialogHandle<Payload>` | – |  |
| `payload` | `Payload` | – | Passed to the dialog’s render function. |
| `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. |

### DialogContent

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` |  |
| `showCloseButton` | `boolean` | `true` | Shows the close button in the corner. |
| `initialFocus` | `boolean \| RefObject \| (openType) => HTMLElement \| boolean` | `First control, or the dialog on touch` |  |
| `finalFocus` | `boolean \| RefObject \| (closeType) => HTMLElement \| boolean` | `The trigger` |  |
| `dir` | `"ltr" \| "rtl"` | – | Set it when the dialog should be right to left. |
| `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. |

### DialogHeader

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. |

### DialogBody

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. |

### DialogFooter

A `<div>` for the actions. Buttons stack full width on phones, with the first one at the bottom.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `showCloseButton` | `boolean` | `false` | Adds an outline Close button after the children. |

| Attribute | Description |
| --- | --- |
| `data-slot="dialog-footer"` | Target the footer in CSS. |

### DialogTitle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<h2>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="dialog-title"` | Target the title in CSS. |

### DialogDescription

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<p>` | Use render={<div />} when it holds several paragraphs. |

| Attribute | Description |
| --- | --- |
| `data-slot="dialog-description"` | Target the description in CSS. |

### DialogClose

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<button>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="dialog-close"` | Target close buttons in CSS. |

### DialogPortal and DialogOverlay

`<DialogContent />` already renders both. Use them only when composing a custom popup.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `keepMounted` | `boolean` | `false` | On the portal, keeps the dialog in the DOM while closed. |

### createDialogHandle

Returns a handle that connects `<DialogTrigger />` elements anywhere on the page to one `<Dialog />`. Type the payload with a generic: `createDialogHandle<{ name: string }>()`.

## Notes for AI assistants

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

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