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
Rohan owes you ₹1,200 for the Farzi Cafe dinner on 12 May. Once settled, this can't be undone.
Priya has pending balances in this group. Removing her now will mark all her expenses as settled. This can't be undone.
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)
Kabir owes you ₹3,200 for the Goa Airbnb deposit. Once settled, this can't be undone.
Sizes
| Property | Value | Notes |
|---|---|---|
| Max width | 480px | Wider than this and the modal loses its dialog feel |
| Max height | calc(100vh − 96px) | Scrolls internally when content exceeds viewport |
| Border radius | 28px (--r-xl) | Larger than card (24px) to distinguish from inline cards |
| Header padding | 28px | Same as body and footer for consistent rhythm |
| Footer padding | 20px 28px 28px | Less top to tighten footer gap |
States
| State | Backdrop | Card | Duration |
|---|---|---|---|
| Closed | opacity 0, pointer-events none | scale(0.96), opacity 0 | — |
| Opening | opacity 0 → 1 | scale(0.96) → scale(1), opacity 0 → 1 | 240ms ease-out |
| Open | rgba(23,21,19,0.5) | scale(1), opacity 1 | — |
| Closing | opacity 1 → 0 | scale(1) → scale(0.96), opacity 1 → 0 | 240ms ease-out |
On dark surfaces
Aarav owes you ₹1,600 for the Swiggy orders. Once settled, this can't be undone.
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
Kabir owes you ₹3,200 for the Goa Airbnb deposit.
| Part | Element | Notes |
|---|---|---|
| Backdrop | .modal-backdrop | Fixed inset-0, rgba(23,21,19,0.5). Click to dismiss. Fades opacity. |
| Card | .modal-card | 28px radius, shadow-float, paper bg. Scales from 0.96 on open. |
| Close button | .modal-close | 32px circle, absolute top-right. Always present. |
| Header | .modal-header | Title (display 22px) + optional subtitle. |
| Body | .modal-body | Prose content, form fields, or structured data. |
| Divider | .modal-divider | Optional — used when body has distinct sections or contains a form. |
| Footer | .modal-footer | Right-aligned by default. Add .stack for vertical stacking. |
Usage
Escape closes. A modal with no way out is a trap.
Accessibility
keydown on Tab.
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.
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
| Class | Purpose |
|---|---|
.modal-backdrop | Fixed scrim — opacity 0 by default, hidden |
.modal-backdrop.open | Shows the modal — opacity 1, pointer-events auto |
.modal-card | Centered dialog card — scales from 0.96 on open |
.modal-card.dark | Dark surface variant |
.modal-close | 32px circle close button, absolute top-right |
.modal-header | Title + subtitle area |
.modal-title | Display 22px/600, right-padded for close button |
.modal-subtitle | 14px muted context line |
.modal-body | Prose or form content area |
.modal-divider | 1px solid line between sections |
.modal-footer | Right-aligned button row |
.modal-footer.stack | Vertical button stack — primary on top |
.modal-btn | Base modal button |
.modal-btn-primary | Ink fill, paper text |
.modal-btn-secondary | Warm fill, ink text |
.modal-btn-danger | Coral fill, white text — destructive only |
.modal-btn-block | Full-width button |
Design tokens used
| Token | Value | Role |
|---|---|---|
--r-xl | 28px | Card border-radius |
--shadow-float | see tokens.css | Card elevation |
--z-modal | 100 | Stacking layer |
--s-paper | #FBF8F0 | Card background |
--d-med | 240ms | Open/close animation |
--ease-out | cubic-bezier(0.23,1,0.32,1) | Animation easing |