Dialog
A modal dialog overlay with customizable header, content, and footer.
Installation
$
Usage
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose } from "@/components/ui/dialog"Examples
Default
A form dialog. DialogFooter showCancelButton adds a Cancel that closes the dialog, placed left of your primary action. initialFocus focuses the first field with the caret at the end instead of selecting its text.
Scrollable
Long content scrolls in a ScrollFade region while the header and footer stay put. gap-0 lets the dividers own their spacing, and every band around one is the dialog's own 24px padding. The max-height goes on the viewport, not the root.
Share
A utility dialog: read-only field with an in-field copy button (Input actionEnd) and a Done action. initialFocus moves focus to the copy button instead of auto-selecting the URL.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| openDialog | boolean | - | Controlled open state. Pair with onOpenChange. |
| onOpenChangeDialog | (open: boolean) => void | - | Called when the open state changes. |
| defaultOpenDialog | boolean | false | Uncontrolled initial open state. |
| modalDialog | boolean | true | When true, blocks interaction with the rest of the page and traps focus. |
| showCloseButtonDialogContent | boolean | true | Shows the X close button in the top-right corner. |
| showCancelButtonDialogFooter | boolean | false | Adds a ghost Cancel button that closes the dialog, placed left of your actions. |
| initialFocusDialogContent | boolean | React.RefObject<HTMLElement | null> | ((openType: InteractionType) => boolean | HTMLElement | null | void) | true | The element to focus when the dialog opens. Pass a ref to focus a specific element, or a function returning an element, true for default behavior, or false/null/void to leave focus alone. |
| renderDialogTrigger, DialogClose | React.ReactElement | - | Replaces the default trigger or close button with your own element, e.g. render={<Button>Done</Button>}. Base UI merges props onto it. |
"use client"
import * as React from "react"
import { IconX as XIcon } from "@tabler/icons-react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/registry/lib/utils"
import { Button } from "@/registry/ui/button"
import { blurredOverlayClass } from "@/registry/lib/tokens"
// @use-when a focused task or confirmation that interrupts the page.
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Backdrop>) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
blurredOverlayClass,
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Popup> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"bg-popover transition-[opacity,scale] motion-reduce:transition-[opacity] ease-out duration-200 data-[ending-style]:duration-150 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:scale-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-(--spacing-rhythm-heading-md) rounded-lg p-6 shadow-ring-lg outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="tap-target ring-offset-background focus-visible:ring-ring-focus data-[open]:bg-accent data-[open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity ease-out hover:opacity-100 focus-visible:ring-[3px] focus-visible:ring-offset-2 outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, style, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn(
"flex flex-col pr-8 text-center sm:text-left",
className
)}
// Title-to-description gap is the trim-pair rung, set inline: the
// title carries `cap-trim`, so this gap paints the exact number that set
// it, and a `gap-2` class would freeze that number past any override.
style={{ ...style, gap: "var(--spacing-rhythm-trim-pair)" }}
{...props}
/>
)
}
function DialogFooter({
className,
showCancelButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCancelButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{showCancelButton && (
<DialogPrimitive.Close render={<Button variant="ghost">Cancel</Button>} />
)}
{children}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
// leading-snug, not leading-none: a wrapping title at 1.0 collides the
// second line's ascenders into the first line's descenders. CardTitle
// reached the same answer. Single-line titles are unaffected: cap-trim
// collapses the box to cap-to-baseline whatever the line-height is, so
// only a wrapped title renders differently.
//
// text-pretty, NOT text-balance, and this is the hub for that decision
// across every left-aligned *Title in the kit. `text-wrap: balance` finds
// the NARROWEST width that still fits the text in the same number of
// lines, so a balanced two-line title does not reach the popup's right
// edge while the description below it (pretty) does. Two left-aligned
// blocks in one stack ending at different right edges reads as an
// indent nobody asked for. Balance is also capped at roughly six lines in
// Chrome and silently does nothing past that, so a long title and a short
// one follow different rules. `pretty` keeps the full measure and only
// pulls a word down to prevent an orphaned last line, which is the defect
// balance was actually hired to fix here.
//
// Balance is still correct where the box HUGS the text or the text is
// CENTRED, because there is no sibling edge to mismatch and the equalised
// lines read as a deliberate shape: Tooltip (w-fit) keeps it, and so do
// the heading caps `.max-w-measure-heading` and
// `.max-w-measure-heading-relaxed`, which Lockup and the hero use and
// which pair their wrap in CSS rather than at the call site.
className={cn("cap-trim text-lg leading-snug font-semibold text-pretty", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm text-pretty", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}