# Modal

**When to use:** Full overlay dialog — forms, detail views, confirmations, wizards that need to block the rest of the UI.

**Keywords:** popup, dialog, overlay, escape, focus trap, scroll lock, sticky header, sticky footer, actions, buttons, bottom sheet, action sheet, mobile, touch

**Import:** `import { Modal } from '@ahrowe/ui'`

The panel itself never scrolls: the header is fixed and the children scroll inside the `body` slot, so the title and close button stay visible no matter how long the content is. Pass `footer` for anything that should stay put below the content, typically the action buttons.

While open, the modal traps focus (focus moves into the panel and returns to whatever was focused before on close), locks scrolling on the page behind it, and closes on Escape. A key already held down when it opens does not repeat into it, so holding Enter on the button that opens it does not go on to press whatever the modal focused first. Modals nest correctly: Escape closes only the topmost one, and the page stays locked until the last one closes. Modal, Popover and Tooltip share one `z-index`, so whichever opened last paints on top, a Modal opened from a Popover included. The same applies to anything opened from inside a modal — a `Menu`, `Dropdown` or `Popover` counts as the layer above it, so Escape closes that first, and the modal's focus trap leaves focus alone while it's open (all three render through the same portal, so their DOM sits beside the modal's panel rather than inside it).

```tsx
import { Modal, ActionButtons } from '@ahrowe/ui';

const [isOpen, setIsOpen] = useState(false);

<button onClick={() => setIsOpen(true)}>Open</button>

<Modal
  isOpen={isOpen}
  title="Edit Profile"
  onClose={() => setIsOpen(false)}
  footer={
    <ActionButtons
      onSubmit={handleSubmit}
      onClose={() => setIsOpen(false)}
      isSubmitButtonLoading={isSaving}
    />
  }
>
  <form onSubmit={handleSubmit}>
    <Input label="Name" formValidator={nameValidator} />
    <Input label="Email" type={InputType.Email} formValidator={emailValidator} />
  </form>
</Modal>

// Modal without header
<Modal isOpen={isOpen} hideHeader onClose={() => setIsOpen(false)}>
  <p>Custom content</p>
</Modal>
```

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `isOpen` | `boolean` | Controls visibility |
| `title` | `ReactNode` | Header title, also the dialog's accessible name |
| `onClose` | `() => void` | Called when the backdrop is clicked, the close button is pressed, or Escape is pressed |
| `footer` | `ReactNode` | Pinned below the scrolling body, e.g. action buttons. Nothing renders when omitted |
| `hideHeader` | `boolean` | Removes the header bar |
| `closeOnEscape` | `boolean` | Close on Escape while this is the topmost modal. Default `true` |
| `presentation` | `Presentation` | Present as a bottom sheet, always (`Sheet`) or only on a small touch screen (`Auto`). `Default`, or omitting it, is the centred dialog |

**Bottom sheet:** `presentation={Presentation.Sheet}` pins the dialog to the bottom of the screen instead of centring it — full width up to 560px, rounded on its top corners only, sliding up rather than scaling in, and lifted above an open on-screen keyboard, shrinking to fit the space that leaves rather than running off the top of the screen. Nothing else changes: the header and footer stay fixed and the body scrolls, which is what a sheet needs anyway, and the backdrop, focus trap, scroll lock and Escape all behave exactly as they do centred. `Presentation.Auto` applies it only when the pointer is coarse *and* the screen is under 768px wide.

`Presentation.Default` means "wherever the component normally puts it", which for a Modal is centred rather than anchored to anything — so an app-wide `<ConfigProvider presentation={Presentation.Default}>` switches sheets off everywhere, this included.

```tsx
import { Modal, Presentation } from '@ahrowe/ui';

<Modal isOpen={isOpen} title="Filters" onClose={close} presentation={Presentation.Auto}>
  <FilterForm />
</Modal>
```

A sheet-presented Modal takes 90% of the screen height rather than the 70% a menu-sized sheet uses, since it carries forms and detail views, and it takes its bottom safe-area padding from `--modal-padding`, so the panel's own spacing carries down to the screen edge. It sets both on the panel itself, so a `ThemeProvider` value for `--sheet-max-height` or `--sheet-safe-padding` never reaches a Modal sheet: override those two per instance via `style`. `--sheet-max-width` it leaves alone, so that one works theme-wide as well. A sheet spans the full width up to it (560px) and centres beyond, full-bleed on a phone and a centred panel on anything wider. `--sheet-max-height` is a ceiling, not a height: whatever it's set to, a sheet never grows past the area actually visible, which is measured live and so accounts for an open keyboard that no viewport unit can see.

**Theming:** override these CSS variables theme-wide via `ThemeProvider` or per instance via `style`; each falls back to a built-in default:

| Variable | Falls back to |
|----------|---------------|
| `--modal-padding` | `var(--spacing-l)` |
| `--modal-gap` | `var(--spacing-l)` — the gap between header, body and footer |
| `--modal-max-width` | `calc(100% - 2 * var(--modal-padding))` |
| `--modal-max-height` | `calc(100% - 2 * var(--modal-padding))` |
| `--modal-body-bleed` | `calc(var(--modal-gap) / 2)` |
| `--modal-scroll-fade` | `calc(var(--modal-gap) + var(--modal-body-bleed))` — `0px` switches the fade off |

The panel has no width of its own, so it grows to whatever its content asks for up to `--modal-max-width`. Give a text-heavy dialog an explicit `width` (or set `--modal-max-width` app-wide) rather than letting a long paragraph decide how wide the dialog is.

The cap is the same at every screen size: the panel never comes closer to the edge than its own padding. There is no breakpoint, so a phone and a desktop leave the same gap and the backdrop stays visible around the panel on both.

`--modal-body-bleed` is how far the body reaches past its own edge, as a negative margin cancelled by an equal padding, so a focus ring or a floating label on the first element inside isn't clipped by the scroll container. Adjust it through this variable rather than setting `padding` on the `body` slot, which drops the negative half and brings the clipping back. Because the two cancel, content always rests exactly `--modal-gap` from the header and footer whatever the bleed is; what the bleed sets is where scrolling content is *clipped*, at `gap - bleed` from each. It defaults to half the gap, putting that edge midway between the chrome and where the content rests.

**Scrolling.** Content dissolves into the edge it is scrolling past rather than being cut off against it: a `mask-image` fades the top of the body while content is hidden above, and the bottom while content continues below. Each end is independent, and both are fully opaque on a dialog that doesn't scroll, so a short modal renders exactly as before. A line sliced clean in half is what a rule or a shadow at that boundary would leave in place, which is why neither is used. Set `styles={{ body: { overflow: 'hidden' } }}` — for content that scrolls itself, say a `VirtualList` — and the fade goes quiet with it, rather than dimming content nobody can scroll to.

To switch it off and go back to a hard edge, set `--modal-scroll-fade: 0px`, per instance via `style` or across an app via `ThemeProvider`. It is also off automatically under `prefers-contrast: more`, since fading text costs contrast at exactly the edge someone may be reading.

**Global defaults:** adopts `ConfigProvider`, e.g. `defaultProps={{ Modal: { presentation: Presentation.Auto } }}` — which also covers `DatePicker`, `TimeInput` and `KlipyPicker` when they open in a Modal via their own `isModal`. `presentation={Presentation.Auto}` on the provider itself sets every sheet-capable component at once. See [ConfigProvider.md](ConfigProvider.md).

The panel also takes the full native `div` attribute set (`id`, `role`, `aria-*`, `data-*`, …). The title's text names the dialog for screen readers. Pass `aria-label` when using `hideHeader`, or when the title has no text of its own (an icon alone): it then takes over from the title.

**Slots:** `container` `backdrop` `modal` `header` `title` `closeButton` `body` `footer`

**Labels:** `close` — the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `ModalLabels`.
