Modal

Modals interrupt the flow to confirm a decision or collect focused input. They always have a dismiss path — close button, backdrop click, or cancel button. Never trap a user. The card animates in from scale(0.96) to scale(1) over 240ms.

Variants

Confirm is the default variant — used for irreversible actions that need a checkpoint before proceeding. "Settle with Rohan?" needs to confirm the amount and direction before money is marked.

Danger is the destructive variant — same structure, but the confirm button is .modal-btn-danger (coral). Always pair with an explicit description of what will be lost or changed.

Form collects focused input in a modal context. The footer uses .stack to stack buttons vertically — primary on top, cancel below. This matches the visual priority for touch targets.

Card previews (static)

Sizes

PropertyValueNotes
Max width480pxWider than this and the modal loses its dialog feel
Max heightcalc(100vh − 96px)Scrolls internally when content exceeds viewport
Border radius28px (--r-xl)Larger than card (24px) to distinguish from inline cards
Header padding28pxSame as body and footer for consistent rhythm
Footer padding20px 28px 28pxLess top to tighten footer gap

States

StateBackdropCardDuration
Closedopacity 0, pointer-events nonescale(0.96), opacity 0
Openingopacity 0 → 1scale(0.96) → scale(1), opacity 0 → 1240ms ease-out
Openrgba(23,21,19,0.5)scale(1), opacity 1
Closingopacity 1 → 0scale(1) → scale(0.96), opacity 1 → 0240ms ease-out

On dark surfaces

The dark modal card uses --dark-elevated as the background. Title and body text shift to dark text tokens. The backdrop is still rgba(23,21,19,0.5) — it reads as a dark scrim on both light and dark pages. The primary button stays ink-fill; the secondary button uses --dark-border fill.

Anatomy

PartElementNotes
Backdrop.modal-backdropFixed inset-0, rgba(23,21,19,0.5). Click to dismiss. Fades opacity.
Card.modal-card28px radius, shadow-float, paper bg. Scales from 0.96 on open.
Close button.modal-close32px circle, absolute top-right. Always present.
Header.modal-headerTitle (display 22px) + optional subtitle.
Body.modal-bodyProse content, form fields, or structured data.
Divider.modal-dividerOptional — used when body has distinct sections or contains a form.
Footer.modal-footerRight-aligned by default. Add .stack for vertical stacking.

Usage

Do Always provide a dismiss path: close button, backdrop click, and a cancel/back action in the footer. Keyboard: Escape closes. A modal with no way out is a trap.
Don't Don't open a modal from inside another modal. If you need a confirmation inside a form modal, use an inline warning message instead. Modal stacking breaks focus management and disorients users.
Do Write the title as a specific question or label: "Settle with Rohan?", "Add expense". The subtitle carries the context: "This will mark ₹1,200 as settled". The body explains the consequence.
Don't Don't put complex multi-step flows in a modal. If the task has more than one major decision or screen, use a bottom sheet or a full page. Modals are for single confirmations and short forms.

Accessibility

Focus trap When the modal opens, focus moves to the first focusable element (usually the close button or first input). Tab cycles within the modal. Focus returns to the trigger element when the modal closes. Use a focus-trap library or implement manually with keydown on Tab.
ARIA The card uses role="dialog" and aria-modal="true". aria-labelledby points to the modal title. The backdrop uses aria-hidden="false" when open to signal the modal is part of the document.
Keyboard Escape closes the modal. Enter on the confirm button submits. Never prevent default keyboard behavior inside a modal. Test with VoiceOver and TalkBack.

Code

HTML

<!-- Trigger -->
<button onclick="document.getElementById('confirm-modal').classList.add('open')">
  Settle up
</button>

<!-- Modal -->
<div class="modal-backdrop" id="confirm-modal"
     onclick="if(event.target===this)this.classList.remove('open')">
  <div class="modal-card" role="dialog" aria-modal="true"
       aria-labelledby="confirm-title">
    <button class="modal-close"
            onclick="document.getElementById('confirm-modal').classList.remove('open')"
            aria-label="Close">×</button>
    <div class="modal-header">
      <div class="modal-title" id="confirm-title">Settle with Rohan?</div>
      <div class="modal-subtitle">This will mark ₹1,200 as settled</div>
    </div>
    <div class="modal-body">
      <p>Rohan owes you ₹1,200 for the Farzi Cafe dinner.</p>
    </div>
    <div class="modal-footer">
      <button class="modal-btn modal-btn-secondary">Cancel</button>
      <button class="modal-btn modal-btn-primary">Settle up →</button>
    </div>
  </div>
</div>

CSS classes

ClassPurpose
.modal-backdropFixed scrim — opacity 0 by default, hidden
.modal-backdrop.openShows the modal — opacity 1, pointer-events auto
.modal-cardCentered dialog card — scales from 0.96 on open
.modal-card.darkDark surface variant
.modal-close32px circle close button, absolute top-right
.modal-headerTitle + subtitle area
.modal-titleDisplay 22px/600, right-padded for close button
.modal-subtitle14px muted context line
.modal-bodyProse or form content area
.modal-divider1px solid line between sections
.modal-footerRight-aligned button row
.modal-footer.stackVertical button stack — primary on top
.modal-btnBase modal button
.modal-btn-primaryInk fill, paper text
.modal-btn-secondaryWarm fill, ink text
.modal-btn-dangerCoral fill, white text — destructive only
.modal-btn-blockFull-width button

Design tokens used

TokenValueRole
--r-xl28pxCard border-radius
--shadow-floatsee tokens.cssCard elevation
--z-modal100Stacking layer
--s-paper#FBF8F0Card background
--d-med240msOpen/close animation
--ease-outcubic-bezier(0.23,1,0.32,1)Animation easing