import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";

import * as ModalStories from "./Modal.stories";

<Meta of={ModalStories} />

# Modal

The **base layer**: a backdrop, a focus trap, Escape, and an outside press — and no
chrome. A dialog is one kind of modal; so is a confirmation, a media lightbox, and
anything else that wants a scrim over the page. `Modal` is that foundation, and
[`Dialog`](?path=/docs/components-dialog--docs) is `Modal` plus the
heading/Close/footer/size chrome the component shipped with before the M8 rebuild.

<Canvas of={ModalStories.Playground} />

## Props

<Controls />

## Portalled, not the top layer

`Modal` renders an `OverlayPanel`, which portals to `document.body`. The old `Modal` rode
the platform top layer (`<dialog>.showModal()`); this one supersedes that
([decision](../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md)),
because the top layer cannot escape an `overflow: hidden` ancestor's clipping in every
case the fleet hit, and a portal does. The objection a portal used to raise — that it
moves the node out of a scoped query — is answered by `useRole` writing `aria-controls` /
`aria-labelledby` across the boundary, and by stories scoping panel queries to
`within(document.body)`.

```tsx
const { hide, isVisible, show } = useVisibility()

<Button onClick={show}>Open</Button>

<Modal aria-label="Read error on title 4" isVisible={isVisible} onClose={hide}>
  …your own markup…
</Modal>
```

`onClose` is every route out — Escape, a backdrop press — so there is one place to wire
and no way to end up with the element shut and the state still open.

## One accessible name, required

A `role="dialog"` takes no name from its content, so pass exactly one of `aria-label` or
`aria-labelledby`. Missing both is a dev-time warning, not a throw.

<Canvas of={ModalStories.Alert} />

## Stacking is zero-config, until it is not

A lone `Modal` renders its own scrim with no setup. Only when modals **stack** — one
opened from inside another — does the app need an `OverlayStackProvider` at its root, so
the N of them share **one** scrim, keep the top live, mark the rest `inert`, and dismiss
top-first. The scroll lock is ref-counted through the stack, so the page frees only when
the last modal closes.

<Canvas of={ModalStories.Interactive} />
