Modal
An accessible dialog overlay built on Base UI. Compound component pattern with Media, Header, Body, Footer, Title, Description, and Close. Five sizes, five roundings, scrim modes (frosted, dim, shadow), full modality spectrum, full-screen takeover, nested dialogs, LTR and RTL support.
pnpm add @tessinaui/uiWhen to use
- A short task that must finish before the person continues: a brief form, a picker, a choice with a few fields. Everything behind the modal waits.
- Rich content that needs focus: a preview, a list or a detail view the person reads in place without losing the page behind it.
- A title that names the task. Every modal has a
ModalTitle, so the dialog is announced by what it does, not by "Dialog". - Buttons that name their action: "Save changes", "Delete project", not "OK" or "Yes". Keep the footer to two actions, three at most.
- A rare interruption. A modal is invasive; open one because the person asked for it, not on page load.
When not to use
- For a destructive, irreversible or critical confirmation. Use AlertDialog, which refuses an outside click and announces itself as an alert.
- For a panel with its own workflow (filters, inspectors) or a long form on a phone. Use Drawer.
- For a message the person may ignore. Use Alert in the page flow, or a Toast for a passing confirmation.
- For a small piece of context next to its trigger. Use Popover.
- For a multi-step flow, a large table or content that scrolls far. Give it a page; a modal is a short interruption.
- For something the person does again and again. Put it on the page.
Installation
pnpm add @tessinaui/uiUsage
import {
Modal,
ModalTrigger,
ModalClose,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalBody,
ModalFooter,
} from "@tessinaui/ui";<Modal>
<ModalTrigger className="...">Open</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>Confirm changes</ModalTitle>
<ModalDescription>This action cannot be undone.</ModalDescription>
</ModalHeader>
<ModalBody>
<p>Additional content goes here.</p>
</ModalBody>
<ModalFooter>
<ModalClose className="...">Cancel</ModalClose>
<ModalClose className="...">Save</ModalClose>
</ModalFooter>
</ModalContent>
</Modal>Examples
Default
A basic modal with a header, body, and footer driven by trigger and close buttons.
Sizes
Five panel widths — sm, md, lg, xl, and full.
Rounded
Five corner-rounding options applied to the panel and its controls.
Curving your own content
Content you put inside — a media slot pinned to the top of the panel — should curve like the chrome around it, or it reads as pasted on. The modal panel publishes its inner radius as a CSS variable:
<img className="rounded-[var(--modal-media-radius)]" />It tracks whatever the component is actually doing: the explicit step when rounded is set (16px at md), and 0 at rounded="none" — so your content squares off exactly when the container does.
Confirmation
A destructive confirmation with a leading icon and an error-intent action.
Form
A modal hosting form fields for editing data.
Non-dismissible
A modal that ignores backdrop and Escape dismissal and hides the X button.
Scrim
Three background treatments: blur frosts the page (default), dim is a plain dark overlay, shadow keeps the page visible with the panel separated by elevation.
Media
A full-bleed hero with the close button floating over it — the announcement pattern.
Prompt
A small input prompt with paired half-width actions.
Result
A success state with a stacked footer — the affirmative action on top, fine print below.
Stepper
A multi-step tour: pagination dots in the footer's start slot, Back/Next trailing.
Plans
A wide pricing chooser — segmented billing toggle in the header's action slot, radio cards, divided footer with fine print.
Share
Invite input, per-person role menus (a popup inside the modal), and a Copy-link action opposite the primary.
Fullscreen
The full-screen takeover: edge-to-edge panel, back + verb CTA in the top bar.
Nested
A confirmation stacked over an open modal — focus moves to the top layer, Escape unwinds one at a time.
On-color
On a coloured Surface the trigger takes the plate's ink and its focus ring from context — no prop needed. The modal stays a standard panel: a portal opens it above the plate rather than on it, so its title, description, body and actions read exactly as they do on the page.
Skeleton
RTL
dir="rtl" is stamped on the portalled viewport: the header icon leads from the right, the close button and footer actions move to the left, and the footer's start slot sits on the right. With dir unset the modal takes the direction around its trigger, or the document's when it opens without one. Its close buttons keep their English name unless dir="rtl" is set or strings overrides it.
API Reference
Modal
Root component. Wraps Dialog.Root from Base UI and provides size and dir context to all children.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "xl" | "full" | "md" | Max-width of the popup panel |
width | "narrow" | "default" | "wide" | "default" | Additional width constraint — narrow (xs), default (from size), wide (3xl) |
rounded | "full" | "lg" | "md" | "sm" | "none" | "full" | Corner radius, cascading to the panel, close button, and media slot |
fullscreen | boolean | false | Edge-to-edge takeover: square corners, full viewport, safe-area padding |
dir | "ltr" | "rtl" | — | Text direction — applied to the popup and all its children. Unset, it is read around the trigger |
strings | Partial<ModalStrings> | English, Arabic under dir="rtl" | Override the close buttons' name (close), in the header and over the media |
open | boolean | — | Controlled open state |
defaultOpen | boolean | false | Uncontrolled initial open state |
onOpenChange | (open: boolean, event: Event) => void | — | Callback when open state changes |
onOpenChangeComplete | (open: boolean) => void | — | Called after the open/close animation settles |
dismissible | boolean | true | Whether backdrop click and Escape close the modal — false keeps only explicit closes |
modal | boolean | "trap-focus" | true | true traps focus, locks scroll, shows the scrim. "trap-focus" traps focus only. false is fully non-modal |
scrim | "blur" | "dim" | "shadow" | "blur" | Background treatment while open (modal only) |
ModalTrigger
Re-export of Dialog.Trigger. Renders a <button> that opens the modal. Apply styles via className or swap the element via the render prop:
<ModalTrigger render={<Button />}>Open modal</ModalTrigger>ModalContent
Renders the popup panel inside a Portal. Includes the backdrop automatically.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "xl" | "full" | from <Modal> | Overrides the size from context |
width | "narrow" | "default" | "wide" | from <Modal> | Overrides the width from context |
rounded | "full" | "lg" | "md" | "sm" | "none" | from <Modal> | Overrides the corner rounding from context |
className | string | — | Extra classes on the popup |
All other native <div> attributes (forwarded to Dialog.Popup) are accepted — including Base UI's initialFocus and finalFocus for custom focus targets.
ModalMedia
Full-bleed media hero placed before ModalHeader — image, illustration, or gradient block. Rounds its top corners with the modal's rounded and clips its content.
| Prop | Type | Default | Description |
|---|---|---|---|
overlayClose | boolean | false | Float a close button over the media (pair with ModalHeader showClose={false}) |
ModalHeader
| Prop | Type | Default | Description |
|---|---|---|---|
icon | React.ReactNode | — | Optional leading icon beside the title |
back | React.ReactNode | — | Leading back affordance — typically <ModalBack aria-label="Back" /> |
action | React.ReactNode | — | Trailing cluster between title and close: a Reset link, a segmented toggle, a header checkbox |
showClose | boolean | true | Whether to render the built-in X close button. Set to false to use a custom <ModalClose> |
divider | boolean | false | Hairline under the header (desktop dialog anatomy) |
ModalBack
Icon-only back button for multi-step modals — ghost, xs, radius from the modal's rounded, arrow auto-flips in RTL. aria-label is required. Everything except icon forwards to IconButton.
ModalTitle
Renders Dialog.Title (linked to aria-labelledby). Accepts all <div> attributes plus className.
ModalDescription
Renders Dialog.Description (linked to aria-describedby). Accepts all <p> attributes plus className.
ModalBody
Scrollable content wrapper. Uses flex-1 overflow-y-auto — grows to fill space between header and footer. At 30rem tall or less — a phone in landscape, or 400% zoom — the whole modal scrolls instead, so the header and footer are never clipped. A modal with no ModalBody scrolls that way whenever it is taller than the screen.
ModalFooter
| Prop | Type | Default | Description |
|---|---|---|---|
stacked | boolean | false | Stack the actions full-width. Put the AFFIRMATIVE button first in the DOM — it renders on top, so reading order and visual order agree |
divider | boolean | false | Hairline above the footer |
start | React.ReactNode | — | Start-aligned slot opposite the actions: stepper dots, a link, fine print. In stacked mode it renders below the buttons |
Default layout is a flex row aligned to the end (justify-end). Keep to at most three actions — two is better, and past three the row stops reading as a choice.
ModalClose
Re-export of Dialog.Close. Renders a <button> that closes the modal. Apply styles via className or swap via render prop:
<ModalClose render={<Button variant="secondary" />}>Cancel</ModalClose>ModalBackdrop
Pre-styled backdrop. Included inside <ModalContent> by default — only use directly for custom layouts.
Sizes
| Size | Max-width | Use case |
|---|---|---|
sm | 384 px | Confirmations, alerts, short forms |
md | 460 px | Standard dialogs (default) |
lg | 512 px | Medium-complexity forms |
xl | 576 px | Rich forms, previews |
full | 100% − 2rem | Full-screen panels |
Animations
The modal uses CSS transitions driven by Base UI's data attributes:
| Attribute | When applied | Effect |
|---|---|---|
data-starting-style | First frame of opening | opacity 0, scale 0.96 |
| (none) | Open steady state | opacity 1, scale 1 |
data-ending-style | During close animation | opacity 0, scale 0.96 |
The backdrop fades in/out independently with transition-opacity. All enter/exit motion is disabled under prefers-reduced-motion: reduce.
Controlled vs Uncontrolled
Uncontrolled — use <ModalTrigger> and <ModalClose> for open/close:
<Modal>
<ModalTrigger className="...">Open</ModalTrigger>
<ModalContent>
<ModalFooter>
<ModalClose className="...">Close</ModalClose>
</ModalFooter>
</ModalContent>
</Modal>Controlled — manage open state yourself:
const [open, setOpen] = useState(false);
<Modal open={open} onOpenChange={setOpen}>
<ModalTrigger onClick={() => setOpen(true)} className="...">Open</ModalTrigger>
<ModalContent>
<ModalFooter>
<button onClick={() => setOpen(false)}>Cancel</button>
<button onClick={() => { doSomething(); setOpen(false); }}>Confirm</button>
</ModalFooter>
</ModalContent>
</Modal>Accessibility
VoiceOver + Safari: passed 2026-09-14. The trigger reads "Open modal, dialog pop up button". Space opens "Save changes, web dialog" with focus on "Close, button"; Tab reaches "Cancel" and "Save changes"; Escape returns to the trigger. NVDA and JAWS are untested.
- Renders as
role="dialog"witharia-modal="true";modal={false}leavesaria-modaloff, because the page behind stays reachable <ModalTitle>is linked viaaria-labelledby<ModalDescription>is linked viaaria-describedby- Focus is trapped inside the dialog when open
- Scroll is locked on
<body>when open - Escape key closes the modal (unless
dismissible={false}) - Focus returns to the trigger when the modal closes
- The backdrop provides a click-outside dismiss target (all
scrimmodes, including the transparentshadow) modal="trap-focus"keeps focus inside without locking page scroll;modal={false}renders no scrim and leaves the page interactive- Every part exposes a stable
data-slotattribute (modal-content,modal-header,modal-footer,modal-media,modal-back, …) for testing and agent tooling - Nested modals move focus to the top-most panel; Escape unwinds one layer at a time