DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
PopoverHoverCardDropdown MenuContextMenuModalAlertDialogDrawerAction Sheet
AI agentsContributing
  1. Components
  2. Modal

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/ui

When 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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import {
  Modal,
  ModalTrigger,
  ModalClose,
  ModalContent,
  ModalHeader,
  ModalTitle,
  ModalDescription,
  ModalBody,
  ModalFooter,
} from "@tessinaui/ui";
example.tsx
<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:

example.tsx
<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.

PropTypeDefaultDescription
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
fullscreenbooleanfalseEdge-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
stringsPartial<ModalStrings>English, Arabic under dir="rtl"Override the close buttons' name (close), in the header and over the media
openboolean—Controlled open state
defaultOpenbooleanfalseUncontrolled 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
dismissiblebooleantrueWhether backdrop click and Escape close the modal — false keeps only explicit closes
modalboolean | "trap-focus"truetrue 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:

example.tsx
<ModalTrigger render={<Button />}>Open modal</ModalTrigger>

ModalContent

Renders the popup panel inside a Portal. Includes the backdrop automatically.

PropTypeDefaultDescription
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
classNamestring—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.

PropTypeDefaultDescription
overlayClosebooleanfalseFloat a close button over the media (pair with ModalHeader showClose={false})

ModalHeader

PropTypeDefaultDescription
iconReact.ReactNode—Optional leading icon beside the title
backReact.ReactNode—Leading back affordance — typically <ModalBack aria-label="Back" />
actionReact.ReactNode—Trailing cluster between title and close: a Reset link, a segmented toggle, a header checkbox
showClosebooleantrueWhether to render the built-in X close button. Set to false to use a custom <ModalClose>
dividerbooleanfalseHairline 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

PropTypeDefaultDescription
stackedbooleanfalseStack the actions full-width. Put the AFFIRMATIVE button first in the DOM — it renders on top, so reading order and visual order agree
dividerbooleanfalseHairline above the footer
startReact.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:

example.tsx
<ModalClose render={<Button variant="secondary" />}>Cancel</ModalClose>

ModalBackdrop

Pre-styled backdrop. Included inside <ModalContent> by default — only use directly for custom layouts.

Sizes

SizeMax-widthUse case
sm384 pxConfirmations, alerts, short forms
md460 pxStandard dialogs (default)
lg512 pxMedium-complexity forms
xl576 pxRich forms, previews
full100% − 2remFull-screen panels

Animations

The modal uses CSS transitions driven by Base UI's data attributes:

AttributeWhen appliedEffect
data-starting-styleFirst frame of openingopacity 0, scale 0.96
(none)Open steady stateopacity 1, scale 1
data-ending-styleDuring close animationopacity 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:

example.tsx
<Modal>
  <ModalTrigger className="...">Open</ModalTrigger>
  <ModalContent>
    <ModalFooter>
      <ModalClose className="...">Close</ModalClose>
    </ModalFooter>
  </ModalContent>
</Modal>

Controlled — manage open state yourself:

example.tsx
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" with aria-modal="true"; modal={false} leaves aria-modal off, because the page behind stays reachable
  • <ModalTitle> is linked via aria-labelledby
  • <ModalDescription> is linked via aria-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 scrim modes, including the transparent shadow)
  • 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-slot attribute (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
ContextMenuAlertDialog

On this page

When to useWhen not to useInstallationUsageExamplesDefaultSizesRoundedCurving your own contentConfirmationFormNon-dismissibleScrimMediaPromptResultStepperPlansShareFullscreenNestedOn-colorSkeletonRTLAPI ReferenceModalModalTriggerModalContentModalMediaModalHeaderModalBackModalTitleModalDescriptionModalBodyModalFooterModalCloseModalBackdropSizesAnimationsControlled vs UncontrolledAccessibility