Modal
A portal-based dialog that floats above all content. Supports keyboard dismissal, click-outside, four sizes, and composable header, body, and footer regions.
Preview
Form modal
Any content works inside the body. Inputs, lists, code blocks — the modal adapts to its content height up to 90vh, after which the body scrolls internally.
Implementation
React: rendered via createPortal into document.body — never trapped inside overflow-hidden ancestors. HTML: uses the native <dialog> element with ::backdrop.
Modal.tsx
tsx
import { useState } from "react";
import { Modal } from "@/components/ui/Modal";
import { Button } from "@/components/ui/Button";
function DeleteDialog() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="danger" onClick={() => setOpen(true)}>
Delete project
</Button>
<Modal
open={open}
onClose={() => setOpen(false)}
title="Confirm deletion"
description="This action cannot be undone."
footer={
<>
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="danger" onClick={() => setOpen(false)}>
Delete
</Button>
</>
}
>
<p>All files and collaborators will be permanently removed.</p>
</Modal>
</>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controls visibility. When false the modal unmounts completely. |
onClose | () => void | — | Called on Escape key, backdrop click, or close button press. |
title | string | — | Renders an <h2> in the header and wires aria-labelledby. |
description | string | — | Subtitle below the title. Wires aria-describedby. |
size | "sm" | "md" | "lg" | "xl" | "md" | Sets max-width: sm=384px, md=448px, lg=512px, xl=672px. |
footer | ReactNode | — | Action row rendered at the bottom with a top border. |
children | ReactNode | — | Body content rendered between the header and footer. |
Motion
Entry and exit should feel fast and purposeful, not decorative.
| Phase | Property | Value | Easing |
|---|---|---|---|
| Enter | opacity | 0 → 1 | 200ms ease-out |
| Enter | scale | 0.96 → 1 | 200ms ease-out |
| Exit | opacity | 1 → 0 | 150ms ease-in |
| Exit | scale | 1 → 0.96 | 150ms ease-in |
| Backdrop | opacity | 0 → 0.5 | 200ms ease-out |
Mobile
On mobile, modals compete with the virtual keyboard and limited screen real estate. The preferred mobile pattern is a bottom sheet, not a centered dialog.
9:41
Settings
Notifications
Privacy
Appearance
Account
| Scenario | Guidance |
|---|---|
| Layout on mobile | Use w-full max-w-none on small screens so the modal fills the viewport width. Set max-height: 90dvh with overflow-y: auto to handle tall content. |
| Bottom sheet | On mobile, slide the panel up from the bottom edge (translate-y transition) instead of scaling from center. Add a 4px drag handle at the top for discoverability. |
| Virtual keyboard | When the modal contains an input, the virtual keyboard pushes content up. Use position: fixed and env(safe-area-inset-bottom) to prevent the close button from being hidden. |
| Swipe to dismiss | Allow a downward swipe gesture to dismiss. Track touchstart/touchmove delta; dismiss when drag exceeds 120px or velocity exceeds 0.5px/ms. |
| Safe area insets | Add padding-bottom: env(safe-area-inset-bottom) to the modal footer to clear the iPhone home indicator and Android gesture bar. |
Accessibility
- →role="dialog" and aria-modal="true" are applied automatically — screen readers announce the overlay context.
- →aria-labelledby and aria-describedby are wired to the title and description when provided.
- →Escape key always dismisses. Body scroll is locked while open.
- →Focus should be moved into the modal on open and restored to the trigger on close (implement with useEffect + ref).
- →Backdrop click dismisses via pointer event on the overlay, not the panel, so accidental interior clicks are safe.