Skip to content
Folio

Overlays

Dialog

A modal surface: portal, scrim, centred panel.

Usage

TSX
import { Dialog } from '@misoto22/design'

Notes

Radix Dialog root + trigger + close, re-exported as typed passthroughs.

Anatomy

Dialog anatomy
ElementDescription
ScrimrequiredThe full-viewport --scrim layer at --z-overlay (200). It is what a click outside lands on, and it paints over everything the page had pinned below that rank — a FloatingIconButton at 100 included. Not over an anchored panel: those sit at 220 precisely so a Select opened inside this dialog is still reachable.
PanelrequiredThe centred box at --z-modal (210), capped at min(92vw, 32rem) wide and 85vh tall, scrolling its own body past that. It centres itself with a translate, which has consequences for anything fixed inside it.
Titlerequiredtitle, rendered as the Radix Title. Always present: when title is omitted a visually hidden one is rendered reading the literal word “Dialog”, and development warns DIALOG_TITLE_MISSING — the fallback exists so an unnamed modal is not shipped, not so one can be.
Descriptiondescription, a quiet line under the title. It shares one wrapper with the title, so hideTitle hides both.
CloseThe 36px X in the top-end corner, rendered while showClose is true (the default) and carrying its own aria-label of “Close”.

Best practices

Do

  • Pass title even when you set hideTitle: with no title at all the fallback accessible name is the literal string “Dialog”, so every unnamed modal in the app is announced as the same thing — and it passes an automated accessibility check while doing it, which is why development warns instead of leaving it to a review.
  • Wrap the cancelling control in DialogClose rather than flipping your own state — the close then runs through Radix, which returns focus to the trigger instead of dropping it at the top of the document.
  • Keep it to what fits. The panel stops at 32rem by 85vh and scrolls its own body past that, so a form long enough to scroll has become a Sheet, which gets the full height of the viewport, or a page.
  • Leave showClose on unless the panel supplies its own exit: Escape and the scrim are the only other ways out and neither is visible, so showClose={false} on a dialog full of content is a room with an unmarked door.

Don’t

  • An OverlayContainer whose element is not positioned hands the dialog the wrong box: naming a container switches the panel from fixed to absolute, and an unpositioned container sends it to the nearest positioned ancestor instead — usually the page, which looks like the container was ignored.
  • Two dialogs open at once are ordered by the DOM, not by a rank: both sit at --z-modal, so the one mounted last paints over the first — a command palette summoned over a dialog lands on top because it opened second, and reversing that order reverses the picture.
  • The panel centres itself with a transform, which makes it the containing block for every position: fixed descendant — a FloatingIconButton dropped inside a dialog pins to the panel’s corner rather than the screen’s.

Examples

a destructive confirm

One question, two answers, and nothing else in the panel. Both controls are wrapped in DialogClose rather than flipping state by hand: the close then runs through Radix, which returns focus to the trigger instead of dropping it at the top of the document. title is what names the modal — leave it out and the fallback accessible name is the literal word Dialog, so every unnamed modal in an app is announced as the same thing. Reach for this shape when the answer is yes or no; anything longer has become a Sheet.

a hidden title

A panel that draws its own header — an eyebrow above the title, a badge beside it — which the plain title line cannot express. hideTitle stops that line rendering twice while title still names the dialog for a screen reader, so pass it even here: with no title at all the accessible name falls back to the literal word Dialog. hideTitle takes the description with it, because the two share one wrapper, so anything the reader has to read goes in the children. Leave showClose on, as it is by default: Escape and the scrim are the only other ways out and neither of them is visible.

or a sheet

The same modal at two lengths. A Dialog stops at 32rem by 85vh and scrolls its own body past that, which is right for one question and wrong for a form: the reader loses the page edge as an anchor and scrolls a box inside a box. A Sheet is the same component docked to an edge — the same scrim, the same focus trap, the same scroll lock — with the full height of the viewport to spend. So the choice is about how much room the content needs, and about nothing else. Both close through DialogClose or SheetClose so focus returns to the trigger.

Parts

Composed at the call site rather than configured through props, so a layout this component did not anticipate is still expressible.

DialogContent

A modal surface: portal → scrim → centred panel.

Radix owns the focus trap, the escape key, the scroll lock and the aria-modal wiring — all of which a hand-rolled dialog gets subtly wrong, usually by leaving focus behind in the page underneath.

Radix requires a Dialog.Title whether or not one is shown, so a dialog without a visible heading still renders a hidden one rather than shipping an unnamed modal — and warns in development, because the fallback it renders is the literal word "Dialog" and a placeholder that passes an accessibility check is how the problem survives a review.

Portals into the element an enclosing OverlayContainer names, and switches from viewport positioning to container positioning when there is one. A fixed panel covers the page whatever it is portalled into, so honouring the container without that swap would have moved the markup and left the picture unchanged.

DialogContent props
PropTypeDefaultDescription
classNamestring
descriptionReactNodeSub-heading under the title.
hideTitlebooleanfalseKeeps the title for assistive tech and hides it visually. For a surface whose purpose is obvious to anyone who can see it — a command palette, a media lightbox — where a printed heading would be furniture. The title itself is never optional: Radix requires one, and a modal with no accessible name drops a screen reader into an unnamed region.
showClosebooleantrueShow the top-right close control (default true).
titleReactNodeHeading text. Pass one even when `hideTitle` is set. Radix requires a title, so omitting it renders a hidden fallback reading the literal word "Dialog" — which satisfies an automated accessibility check and announces every unnamed modal in the application as the same thing. Development says so out loud.

Also accepts everything in Omit<ComponentProps<typeof DialogPrimitive.Content>, 'title' | 'className'>. Those are forwarded to the underlying element and are not listed row by row.

Re-exports

Dialog = DialogPrimitive.Root

Radix Dialog root + trigger + close, re-exported as typed passthroughs.

DialogTrigger = DialogPrimitive.Trigger
DialogClose = DialogPrimitive.Close

Keyboard

Dialog keyboard interactions
KeyDoes
EscapeCloses it, and focus returns to the trigger it came from.
TabCycles inside the dialog; focus cannot leave while it is open.

Accessibility

  • Radix owns the focus trap, Escape, the scroll lock and aria-modal.
  • A dialog without a visible heading still renders a hidden title, rather than shipping an unnamed modal.