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.
Playground
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 M3 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.
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" | "ltr" | Text direction — applied to the popup and all its children |
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.
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 (M3/HIG rule) |
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 (M3 allows two; Apple/Fluent/Carbon cap at three).
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
- Renders as
role="dialog"witharia-modal="true" <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