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)