Browse docs
Browse docs
A declarative, controlled modal dialog. You own the open state; <Dialog> renders the overlay, focus-traps inside, restores focus on close, and portals its content to document.body so the parent's overflow: hidden can't clip it.
import { Dialog } from '@dashforge/tw';
<Dialog open={open} onOpenChange={setOpen} title="Confirm">
Body content.
</Dialog>Use <Dialog> when you want your components inside the modal box and full control over the JSX tree. For imperative binary confirmations (await confirm({...})), reach for <ConfirmDialog> instead — same underlying primitive, ergonomic API.
import { useState } from 'react';
import { Dialog, Button, Stack, Typography } from '@dashforge/tw';
function EditWorkspace() {
const [open, setOpen] = useState(false);
return (
<>
<Button color="primary" onClick={() => setOpen(true)}>
Open dialog
</Button>
<Dialog
open={open}
onOpenChange={setOpen}
title="Workspace details"
description="Update the workspace name and description."
>
<Stack gap={3}>
<Typography>Everyone in the workspace will see the new name in the sidebar.</Typography>
<Stack direction="row" gap={2} justify="end">
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button color="primary" onClick={() => setOpen(false)}>Save</Button>
</Stack>
</Stack>
</Dialog>
</>
);
}The open state lives in your component. onOpenChange fires when the user clicks the close button, the backdrop, or presses Escape.
import { useState } from 'react';
import { Dialog, Button, Stack, Typography } from '@dashforge/tw';
function App() {
const [open, setOpen] = useState(false);
return (
<>
<Button color="primary" onClick={() => setOpen(true)}>
Open dialog
</Button>
<Dialog
open={open}
onOpenChange={setOpen}
title="Workspace details"
description="Update the workspace name and description."
>
<Stack gap={3}>
<Typography variant="body1">
Everyone in the workspace will see the new name in the sidebar.
</Typography>
<Stack direction="row" gap={2} justify="end">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button color="primary" onClick={() => setOpen(false)}>
Save
</Button>
</Stack>
</Stack>
</Dialog>
</>
);
}Three max-widths — sm (28rem) for compact confirmations, md (32rem, default) for the common case, lg (48rem) for form-shaped bodies.
import { useState } from 'react';
import { Dialog, Button, Stack } from '@dashforge/tw';
function App() {
const [size, setSize] = useState<'sm' | 'md' | 'lg' | null>(null);
return (
<>
<Stack direction="row" gap={2}>
<Button variant="outline" onClick={() => setSize('sm')}>Small</Button>
<Button variant="outline" onClick={() => setSize('md')}>Medium</Button>
<Button variant="outline" onClick={() => setSize('lg')}>Large</Button>
</Stack>
<Dialog
open={size !== null}
onOpenChange={(open) => !open && setSize(null)}
size={size ?? 'md'}
title={`Dialog — size="${size ?? 'md'}"`}
>
Content max-width scales with the size tier.
</Dialog>
</>
);
}<Dialog open={open} onOpenChange={setOpen} size="sm" title="…">…</Dialog>
<Dialog open={open} onOpenChange={setOpen} size="md" title="…">…</Dialog>
<Dialog open={open} onOpenChange={setOpen} size="lg" title="…">…</Dialog>For flows where the user must engage — legal acceptance, blocking OS-level errors — disable the escape hatches:
<Dialog
open={open}
onOpenChange={setOpen}
title="Terms of service"
disableBackdropClose
disableEscapeClose
showCloseButton={false}
>
<TermsBody />
<Stack direction="row" gap={2} justify="end">
<Button variant="outline" onClick={onDecline}>Decline</Button>
<Button color="primary" onClick={onAccept}>Accept</Button>
</Stack>
</Dialog>Use sparingly — most users expect Escape to work. When you disable it, make the primary action button unambiguous.
title and description are optional — the a11y wiring adapts. When you omit both, provide an aria-label on the content via slotProps.content for screen readers.
<Dialog
open={open}
onOpenChange={setOpen}
slotProps={{ content: { className: 'aria-label' } }}
>
<CustomHeader />
<CustomBody />
</Dialog>The dialog is just the surface. Compose whatever you need inside — the standard pattern uses a footer with <Button> actions:
<Dialog open={open} onOpenChange={setOpen} title="Publish article?">
<Stack gap={3}>
<Typography>Once published, the article will be visible to everyone with the link.</Typography>
<Stack direction="row" gap={2} justify="end">
<Button variant="outline" onClick={() => setOpen(false)}>Not yet</Button>
<Button color="primary" onClick={publish}>Publish</Button>
</Stack>
</Stack>
</Dialog>Configure <Dialog> defaults application-wide.
import { patchTheme } from '@dashforge/tw-theme';
patchTheme({
components: {
Dialog: {
defaults: { size: 'md', showCloseButton: true, disableBackdropClose: false, disableEscapeClose: false },
},
},
});Configurable axes (DialogVariantProps):
| Axis | Type | Notes |
|---|---|---|
size | 'sm' | 'md' | 'lg' | Content max-width tier. |
showCloseButton | boolean | Auto-render the × close button in the top-right. |
disableBackdropClose | boolean | Prevent close on backdrop click. |
disableEscapeClose | boolean | Prevent close on Escape key. |
Non-visual props (open, onOpenChange, title, description, children) are per-instance — they carry the dialog's identity and content.
Precedence chain (lowest → highest):
defaultVariants from the internal dialogVariants recipe (size: 'md').theme.components.Dialog.defaults (application-wide).<Dialog size="lg" />) — wins over theme.sx — appended to the content root and wins over conflicting classes via tailwind-merge.TypeScript: theme.components.Dialog.defaults autocompletes to the four axes above.
Reactivity: useComponentDefaults('Dialog') subscribes to the theme store — patchTheme re-renders every mounted instance inheriting the changed axis.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Dialog body — usually a <DialogContent> subtree. |
onOpenChange | (open: boolean) => void | — | Called when the dialog requests to change open state. |
open | boolean | — | Controlled open state. |
description | ReactNode | — | Dialog description (announced via aria-describedby). |
disableBackdropClose | boolean | false | Disable closing on backdrop click. |
disableEscapeClose | boolean | false | Disable closing on Escape key. |
showCloseButton | boolean | true | Show the × close button in the top-right corner. |
size | 'md' | 'sm' | 'lg' | 'md' | Content max-width tier — sm (28rem) / md (32rem, default) / lg (48rem). Useful for tuning to the content density. |
slotProps | DialogSlotProps | — | Per-slot overrides. |
sx | string | — | Root className shortcut on the content element. |
title | ReactNode | — | Dialog title (screen-reader announced via aria-labelledby). |
<Dialog> is a compound component with 7 named slots. Each slot accepts a { className?: string } override via slotProps:
| Slot | Purpose |
|---|---|
overlay | The backdrop layer (Radix DialogOverlay). |
content | The modal box surface (bg + border + shadow + size-driven max-width). |
title | The heading rendered when title is set (linked to content via aria-labelledby). |
description | The subtitle rendered when description is set (linked via aria-describedby). |
closeButton | The auto-rendered × close button (when showCloseButton is true). |
body | The main content region — receives children. |
actions | Optional bottom action-row container (for the typical Cancel/Confirm pattern). |
<Dialog
open={open}
onOpenChange={setOpen}
title="Fine print"
slotProps={{
overlay: { className: 'bg-black/60' },
content: { className: 'max-w-3xl' },
title: { className: 'text-lg' },
}}
>
Body
</Dialog>Use sx for a class override on the content root; use slotProps when you need to target a specific inner region (overlay opacity, title typography, action-row justification).
document.body via Radix's DialogPortal. The parent's overflow: hidden won't clip the modal. z-index sits above navigation but below toasts (Snackbar's z-50 track).content gets focus on open; focus returns to the trigger element on close. Trap prevents Tab from escaping the modal.prefers-reduced-motion and collapse to an instant appear/disappear when the user opts out.<body> gets overflow: hidden so the page underneath can't scroll. Restored on close.<ConfirmDialog> instead: if the dialog is a binary yes/no confirmation and you want to await confirm({...}) from an async function, <ConfirmDialog> gives you that ergonomic API for free.<Popover> instead: if the surface should attach to a specific trigger rather than sit in the middle of the viewport (e.g. an action menu, a settings panel next to a button), <Popover> is the right primitive — it's positioned relative to the trigger and doesn't lock scroll.