Accessible, animated modal dialog component with compound sub-components for building structured dialogs with backdrop, scroll-lock, and keyboard dismiss support.
## Key Components
| Export | Description |
|---|---|
| `ModalV2` | Root modal container managing open/close state, backdrop, animations, scroll lock, focus management, and Escape key handling. `size?: ModalV2Size` selects the panel width |
| `ModalV2Header` | Header row with auto-injected close button (desktop only) via `ModalContext` |
| `ModalV2Title` | Semantic `
` styled with design system text tokens |
| `ModalV2Content` | Scrollable body area with `overflow-y-auto` and flex-grow |
| `ModalV2TwoColumn` | Two-column editor body — use INSTEAD OF `ModalV2Content` on a `size="wide"` modal. Props `{ left, right, className? }` |
| `ModalV2Footer` | Horizontal action row for buttons or other controls |
## Sizes
`ModalV2Size` = `'default' | 'medium' | 'wide'` (default `'default'`). Every arm keeps a
`calc(100vw-2rem)` arm so content can never stretch the panel past the viewport on narrow screens.
| Size | Width | Use |
|---|---|---|
| `default` | `28rem` | Confirms and short prompts |
| `medium` | `42rem` | Single-column forms |
| `wide` | `1400px` | Two-column editors and master-detail panes. Also FIXES the desktop height (`md:h-[min(90dvh,100%-2rem)]`) so the panel doesn't jump as an entity hydrates in |
Prefer the `size` prop over a call-site `max-w-*`. The size class is emitted before `className`,
so an override still wins where one is genuinely needed.
**Internal:**
- `ModalContext` — React context passing `onClose` to descendants (used by `ModalV2Header`)
- `ANIMATION_DURATION` — `200ms` constant kept in sync with Tailwind `duration-200` utilities; controls unmount delay so exit animations complete before DOM removal
## Usage Example
```typescript
import {
ModalV2,
ModalV2Header,
ModalV2Title,
ModalV2Content,
ModalV2Footer,
} from "@/components/modal-v2"
function MyDialog() {
const [open, setOpen] = React.useState(false)
return (
<>
setOpen(false)}>
Confirm Action
Are you sure you want to proceed?
>
)
}
```
## Behavior Notes
- **Scroll lock** — `react-remove-scroll` (the same library Radix primitives use internally, so it composes with the lock a Select opened inside the modal adds on top). Rendered with `forwardProps` so no extra wrapper `
` lands in a flex/grid parent's flow
- **Escape key** — document-level listener, but top-of-stack only: with stacked modals (a confirm above a form) one Escape closes just the top one, and a nested Radix layer's `preventDefault`ed Escape closes only that layer
- **Focus** — focus moves into the panel on open, is CONTAINED there while it is topmost (with bounded re-asserts for focus dropped by node removal), Tab cycles inside it, and focus returns to the opener on close. Portaled Radix layers (`[data-radix-popper-content-wrapper]`, `[role=listbox]`, `[role=menu]`) are exempt
- **No focus ring on the panel** — the panel is `tabIndex={-1}`, a focus TARGET rather than a control, so it carries `outline-none`. Opening by click hid the UA ring via `:focus-visible` heuristics, but a modal opened straight from a URL on page load has no preceding pointer event and the browser drew a ring around the whole dialog on every refresh. Controls inside keep their own rings
- **Backdrop click** — calls `onClose`
- **Two-column scroll model** — below `lg` there is one column and the outer region scrolls (identical to a single-column modal on a phone); from `lg` up the outer region is `overflow-hidden` and each column scrolls independently
- **Software keyboard** — the wrapper pads by `--of-keyboard-inset` rather than shrinking, since neither mobile shell shrinks the layout viewport when the IME opens
- **Animations** — slide-up on mobile, zoom on desktop; `fill-mode-forwards` prevents opacity flash between animation end and unmount
- **Close button** — rendered inside `ModalV2Header`, visible on `md:` breakpoint and above only
## Source
[`modal-v2.tsx`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/modal-v2.tsx)