Dialog
A modal dialog built on Base UI’s Dialog primitive. Includes a backdrop overlay, centered popup with scale/fade animation, title, and description.
Import
import { Dialog } from "chunks-ui";Basic Usage
<Dialog.Root>
<Dialog.Trigger>Open Dialog</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Popup>
<Dialog.Title>Are you sure?</Dialog.Title>
<Dialog.Description>
This action cannot be undone.
</Dialog.Description>
<div className="mt-4 flex justify-end gap-2">
<Dialog.Close>Cancel</Dialog.Close>
<Button color="destructive">Delete</Button>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>With Backdrop
The Dialog.Backdrop renders a semi-transparent black overlay (bg-black/50) with a fade transition. A dialog opened from inside another dialog or drawer renders its backdrop too, so it dims the parent (Base UI skips nested backdrops by default; pass forceRender={false} to do the same):
<Dialog.Root>
<Dialog.Trigger>Show</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Popup>
<Dialog.Title>Dialog with backdrop</Dialog.Title>
<Dialog.Description>
The backdrop dims the content behind the dialog.
</Dialog.Description>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>Controlled
function ControlledDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Popup>
<Dialog.Title>Controlled Dialog</Dialog.Title>
<Dialog.Description>You can control the open state.</Dialog.Description>
<Dialog.Close>Done</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}Long content
Put long content in a Dialog.Body between the Title and the actions. The popup is at most the viewport height less 2rem. The Title, the Description and the actions stay in place, and only the Body scrolls:
<Dialog.Root>
<Dialog.Trigger>Read the terms</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Popup>
<Dialog.Title>Terms of service</Dialog.Title>
<Dialog.Description>Read the terms before you accept them.</Dialog.Description>
<Dialog.Body className="mt-4 space-y-3 text-sm">
<p>These terms apply to your use of the service.</p>
<p>Keep your account details safe.</p>
</Dialog.Body>
<div className="mt-4 flex justify-end gap-2">
<Dialog.Close render={<Button variant="outlined" color="secondary" />}>
Cancel
</Dialog.Close>
<Dialog.Close render={<Button />}>Accept</Dialog.Close>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>- A Body whose content fits is skipped by the keyboard, and the first action takes the initial focus, so a short Body adds no tab stop. In jsdom, which has no layout, no Body is a tab stop; to test the overflow path, shim
scrollHeightandclientHeight(scrollWidthandclientWidthfor sideways overflow) on the element. Dialog.Bodymust be a direct child ofDialog.Popup. Wrapping the Body in a<form>or a<div>loses the layout: the popup scrolls as a whole again, the actions are not pinned and the Body does not scroll. For a form, see below.- With a Body, the popup is a flex column. Bare inline children, such as a lone
Dialog.Closebutton, stretch to the full width, and the margins between children do not collapse. Wrap the actions in a block, as in the example. - The Body has a 0.25rem gutter (
-m-1 p-1), so the focus rings of full-width fields are not clipped at its edges. The content does not move. A margin class on the Body, such asmt-4, replaces the negative margin on that side and adds the 0.25rem of padding. - While its content overflows, the Body is a tab stop, so the keyboard can scroll it (arrow keys, Page Up and Page Down, Home and End); it measures itself again when its box or its content changes, including a child that grows on its own, such as an image that loads or a list that fills in. It is a region that the Title names.
- While it overflows, the Body also has a
data-overflowingattribute, so a class can show that there is more to scroll, such as a line between the content and the actions:<Dialog.Body className="data-overflowing:border-b">. - When the Body overflows and nothing tabbable comes before it, the Body gets the focus when the dialog opens with a keyboard or a mouse. The dialog opens at the top of the content, and a screen reader reads the Body’s name. On touch, Base UI focuses the popup itself. The Body shows a focus ring when the dialog was opened with the keyboard.
- For a form in the Body, pass
initialFocusonDialog.Popupa ref to the first field.
Form in a Body
Render the Body as the <form> with render, as on any Base UI part, and let the submit button in the actions point at it with the form attribute. The form is the Body: it keeps the scroll region, its name and its tab stop, and the actions stay in place while the fields scroll:
<Dialog.Root>
<Dialog.Trigger>New task</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Popup>
<Dialog.Title>New task</Dialog.Title>
<Dialog.Body
render={<form id="new-task" onSubmit={(e) => e.preventDefault()} />}
className="mt-4 space-y-3"
>
<Input aria-label="Title" placeholder="Title" />
<Textarea aria-label="Notes" placeholder="Notes" />
</Dialog.Body>
<div className="mt-4 flex justify-end gap-2">
<Dialog.Close render={<Button variant="outlined" color="secondary" />}>
Cancel
</Dialog.Close>
<Button type="submit" form="new-task">
Create
</Button>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>A <form> inside the Body, around the fields, works the same way; a <form> around the Body and the actions does not (the Body must be a direct child of the Popup, as above). render also takes a function of the props, as on Base UI parts. With a function, put the name on the Body (aria-label or aria-labelledby): the Body reads a name from its own props and from a render element, not from what a function returns, so the Title’s name wins over one set inside the function.
Without a Body, content taller than the popup makes the whole popup scroll. Base UI focuses the first tabbable element and scrolls to it, so a dialog with its actions only at the bottom opens at the bottom. Pass initialFocus a ref to a top element, such as the Close button or the Title with tabIndex={-1}, to open at the top.
With nothing tabbable inside, or with initialFocus on the popup itself, the popup takes focus. When the browser shows focus there (after keyboard use, for example), the ring is the library focus ring, as on a Button: 2px of --ring, 2px outside the popup’s edge.
Animation
The popup enters with a scale-up and fade (from scale-95 and opacity-0). With Motion installed it uses the overlay spring (springs.overlay). Without Motion it is a CSS transition of opacity and scale (micro-interactions: 300ms, cubic-bezier(0.4, 0, 0.2, 1)). Reduced motion turns off both. The backdrop fades in and out independently.
render on Dialog.Popup or Dialog.Backdrop replaces the element the spring animates, so a popup or backdrop with render uses the CSS transition even with Motion installed. To restyle either, pass className; to change the element of the content, put render on Dialog.Body.
Popups in a dialog
A Menu, Select, Popover or other popup opened from the dialog is placed against Dialog.Portal, a box fixed to the top of the viewport, so it stays at its trigger when the page behind scrolls or changes height. That has two limits:
- A popup taller than the viewport is clipped at the viewport’s edge: a modal dialog locks the page’s scroll, and in a
modal={false}ormodal="trap-focus"dialog the page scrolls but the fixed box adds no height to it. Menu and Combobox cap their height at the space available (--available-height) and scroll. A Popover does not, nor a Select that is not aligned over its trigger (withalignItemWithTrigger={false}onSelect.Positioner, or opened by touch): cap them at--available-heightand let them scroll. In a Popover, scroll an element insidePopover.Content, asoverflowon the popup clipsPopover.Arrow. - A position class in the Portal’s
className, such asabsolute, replacesfixed, and the popups move away from their triggers again when the page behind scrolls or changes height.
Compound Parts
| Part | Description |
|---|---|
Dialog.Root | State container. Manages open/close. |
Dialog.Trigger | Button that opens the dialog. |
Dialog.Portal | Renders children in a portal outside the DOM tree. Puts the dialog on the z-modals (700) layer; popups, dialogs and drawers opened from it stack above it. |
Dialog.Backdrop | Semi-transparent overlay behind the dialog. Also renders for a nested dialog, so it dims the parent. |
Dialog.Popup | Centered content panel. Max width 28rem (max-w-md), and never wider than the viewport less a 1rem gutter on each side, so its focus ring stays on the page; a max-w-* class in className replaces that cap. Max height the viewport less 2rem, then its content scrolls. |
Dialog.Title | Dialog heading. Rendered as a semibold text-lg. |
Dialog.Description | Supporting text below the title. Rendered in text-muted-foreground. |
Dialog.Body | Optional scrolling area for long content, between the Title and the actions. Only the Body scrolls; the other parts stay in place. |
Dialog.Close | Button that closes the dialog. |
Props
All Dialog parts except Dialog.Body are pass-throughs to the underlying Base UI Dialog. Dialog.Body is a <section> and takes its props, plus Base UI’s render to replace the element (see the form above). Pass aria-label or aria-labelledby, on the Body or on the render element, to give it a name other than the Title; with a function render, put it on the Body. A <div> or <span> render element, or one a function render returns, gets role="region" from the Body while the Body is named. A <form> and any role you set are kept. A component in render gets none: give its element a role, or pass role="region" on the Body.