# Modal

> A frosted dialog with a focus trap, scroll lock, inert page, scroll-aware edges and a bottom sheet on phones.

Category: Overlays. Docs page: https://rime.mjsons.net/components/modal

## Installation

```bash
npx shadcn@latest add KezLahd/rime/modal
```

Modal portals a dialog over a deep scrim: focus moves in (to initialFocus, the first field, or the first button), Tab is trapped, Esc and a scrim click close it unless dismissible is false or busy is set, the page behind goes inert, and focus returns to whatever opened it.

Everything in the header sits on one left edge: the tone's icon tile and the close button on one row, then the title and description full width beneath; nothing is stacked over the title. tone tints the header wash and the tile (danger, success, info); progress draws a segmented step rule for multi-step flows.

The body scrolls on its own. Its scroll edges are the portable pattern from components/ui/Scroll: a hairline and soft shade under the header once content has scrolled (data-scrolled) and over the footer while there is more below (data-more), written straight to the DOM by useScrollEdges with a passive listener, plus thin tinted scrollbars and contained overscroll. Under 640px every size becomes a bottom sheet (or the full screen with mobile="fullscreen").

Widths are tokens: --modal-width-sm 480px, -md 640px, -lg 880px. meta puts a quiet line under the title (a reference, "Step 1 of 2"); nothing is stacked over the title. inline renders the open panel in place, with no portal, scrim, focus trap or scroll lock, for docs and design reviews.

## Usage

```tsx
import { Button, Modal } from "@/components/ui";

<Modal
  open={open}
  onClose={() => setOpen(false)}
  title="Contact support"
  description="Ask a question about invoice INV-1042."
  footer={
    <>
      <Button variant="secondary" onClick={() => setOpen(false)}>Cancel</Button>
      <Button onClick={send}>Send question</Button>
    </>
  }
>
  …form fields…
</Modal>
```

## Props: Modal

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` (required) | `boolean` |  |  |
| `onClose` (required) | `() => void` |  | Called on Esc, scrim click and the close button, unless dismissible is false. |
| `title` (required) | `ReactNode` |  |  |
| `meta` | `ReactNode` |  | A quiet line under the title: a reference, "Step 1 of 2". Never stacked over the title. |
| `description` | `ReactNode` |  |  |
| `children` | `ReactNode` |  |  |
| `inline` | `boolean` |  | Render the open panel in place, in normal flow: no portal, no scrim, no inert page, no scroll lock, no focus trap and no exit animation. For docs, thumbnails and design reviews, where many can render at once. |
| `footer` | `ReactNode` |  | Buttons, right-aligned. Primary last, and one gradient primary at most. |
| `footerStart` | `ReactNode` |  | Left side of the footer: a line of secondary text, a link, a Back button. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | sm 480, md 640, lg 880 (--modal-width-*). Under 640px every size becomes a bottom sheet. |
| `mobile` | `"sheet" \| "fullscreen"` | `"sheet"` | Under 640px: a bottom sheet (default) or the full screen, for long forms. |
| `tone` | `ModalTone` | `"default"` | Tints the header wash and the icon tile. danger, success and info bring their own icon; pass icon={false} to drop it. |
| `icon` | `ReactNode` |  |  |
| `progress` | `{ step: number; of: number }` |  | A multi-step flow: draws the segmented step rule under the heading. |
| `onScrollEnd` | `() => void` |  | Called when the body has been scrolled to its end (and at once if it does not overflow). Gate a terms "Accept" on it: see the scroll-gated accept example. |
| `dismissible` | `boolean` | `true` | false while a request is in flight: Esc, scrim and close are ignored. |
| `busy` | `boolean` | `false` | A request is in flight: sets aria-busy and locks dismissal. |
| `initialFocus` | `RefObject<HTMLElement \| null>` |  | Focused on open. Defaults to the first field or button in the body. |
| `closeLabel` | `string` | `"Close"` |  |
| `className` | `string` |  |  |

## Props: ConfirmModal

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` (required) | `boolean` |  |  |
| `onClose` (required) | `() => void` |  |  |
| `title` (required) | `ReactNode` |  |  |
| `meta` | `string` |  | A quiet line under the title; two-step flows prefix it with "Step 1 of 2". |
| `description` | `ReactNode` |  |  |
| `inline` | `boolean` |  | Render the open dialog in place (see Modal's inline). |
| `children` | `ReactNode` |  | Step one: the details or form (e.g. withdrawal reason). |
| `review` | `ReactNode` |  | Step two: what will happen, stated plainly. When given, the first button reads proceedLabel and leads here; the destructive button is only on this step. Omit for a single-step confirm. |
| `reviewTitle` | `ReactNode` |  |  |
| `reviewDescription` | `ReactNode` |  | Replaces description on step two. Omitted: step two has none. |
| `proceedLabel` | `string` | `"Continue"` |  |
| `confirmLabel` (required) | `string` |  |  |
| `cancelLabel` | `string` | `"Cancel"` |  |
| `tone` | `ModalTone` | `"default"` |  |
| `icon` | `ReactNode` |  | Overrides the tone's icon tile; false drops it. |
| `onProceed` | `() => boolean` |  | Return false to stay on step one (failed validation). |
| `onConfirm` (required) | `() => void` |  |  |
| `busy` | `boolean` |  | The confirm request is in flight. Locks the dialog. |
| `busyLabel` | `string` |  |  |
| `size` | `ModalProps["size"]` | `"md"` |  |
| `mobile` | `ModalProps["mobile"]` |  |  |

## Examples

### Open

inline renders the panel in place: no portal, scrim, focus trap or scroll lock. For docs and reviews.

```tsx
<Modal inline open onClose={close} title="Contact support" meta="Invoice INV-1042" description="Ask a question about this invoice." footer={…}>
  <Field label="Your question"><TextInput /></Field>
</Modal>
```

### Form in a modal

```tsx
<Modal open={open} onClose={close} title="Contact support" footerStart="Recorded on the activity log." footer={…}>
  <Field label="What is it about?" required><Select … /></Field>
</Modal>
```

### Long content: scroll edges

Scroll the body: the header shade appears once content passes under it, and the footer shade shows while there is more.

```tsx
<Modal open={open} onClose={close} size="lg" title="Workspace terms" footer={<Button onClick={close}>I agree</Button>}>
  {terms}
</Modal>
```

### Terms with a scroll-gated accept

Accept stays disabled until the body has been scrolled to the end. onScrollEnd comes from useScrollEdges, the same listener that clears data-more when nothing is left below. Open it and scroll.

```tsx
const [read, setRead] = useState(false);
const onEnd = useCallback(() => setRead(true), []);

<Modal
  open={open}
  onClose={close}
  size="lg"
  title="Workspace terms"
  onScrollEnd={onEnd}
  footerStart={read ? <span role="status">You have reached the end</span> : <span role="status" id="terms-hint">Scroll to the end to accept</span>}
  footer={<Button disabled={!read} aria-describedby={read ? undefined : "terms-hint"} onClick={accept}>Accept terms</Button>}
>
  {terms}
</Modal>
```

### ConfirmModal, danger, two steps

The irreversible button only appears on the review step, so a stray Enter cannot confirm.

```tsx
<ConfirmModal
  open={open}
  onClose={close}
  tone="danger"
  title="Delete Atlas redesign"
  review={<p>Files are removed from the shared drive immediately.</p>}
  proceedLabel="Review deletion"
  confirmLabel="Delete project"
  onConfirm={remove}
  busy={pending}
>
  …reason fields…
</ConfirmModal>
```

## Theme tokens

Per-component tokens. Undeclared by default: the default below is the fallback the CSS uses. Set any of them at :root or on a wrapper to restyle every instance.

| Token | Default |
| --- | --- |
| `--modal-inline-width` | `520px` |
| `--modal-inline-width-lg` | `640px` |
| `--modal-pad-x` | `calc(24px * var(--density))` |
| `--modal-radius` | `var(--r-xl)` |
| `--modal-radius-lg` | `var(--r-2xl)` |
| `--modal-width-lg` | `880px` |
| `--modal-width-md` | `640px` |
| `--modal-width-sm` | `480px` |

System tokens it reads: `--brand-gradient`, `--danger-gradient`, `--danger-ink`, `--density`, `--dur-slow`, `--ease`, `--ease-out`, `--focus-color`, `--font-display`, `--info-ink`, `--ink-body`, `--ink-brand`, `--ink-heading`, `--ink-muted`, `--ink-secondary`, `--modal-blur`, `--modal-scrim`, `--modal-scrim-blur`, `--modal-shadow`, `--modal-surface`, `--modal-wash-opacity`, `--r-2xl`, `--r-full`, `--r-md`, `--r-xl`, `--rgb-brand`, `--rgb-brand-deep`, `--rgb-brand-soft`, `--rgb-danger`, `--scroll-edge-bottom`, `--scroll-edge-left`, `--scroll-edge-right`, `--scroll-edge-top`, `--scrollbar-thumb`, `--scrollbar-track`, `--scrollbar-width`, `--shadow-specular`, `--success-gradient`, `--success-ink`, `--success-solid`, `--text-body`, `--text-caption`, `--text-small`, `--tracking-display`, `--z-modal`.

## Accessibility

- role="dialog" ("alertdialog" for tone="danger") with aria-modal, aria-labelledby on the title and aria-describedby on the description.
- Focus trap with Tab and Shift+Tab, Esc to close, focus returned to the opener; the background is inert, except live regions (toasts) so failures are still announced.
- busy locks dismissal and sets aria-busy while a request is in flight.
- Reduced motion: everything fades in place; nothing travels or scales.

## Do

- Keep one primary action, last in the footer.
- Use ConfirmModal for any irreversible action; TypeToConfirmModal when the blast radius is large.
- Use footerStart for a line of context or a Back button.

## Don't

- Don't stack modals unless the second is a confirm of the first.
- Don't put a coloured side bar or a filled header on a modal; tone is a soft wash only.
- Don't open a modal on page load.

Related: https://rime.mjsons.net/components/button, https://rime.mjsons.net/components/scroll-area, https://rime.mjsons.net/components/type-to-confirm
