Components/Overlay
Modal
Modals are dialogs that open over the page and hold the reader’s attention until they are dealt with. They suit a task that has to be finished, or abandoned, before anything else can continue. Everything behind one waits, which makes a modal a demand on the reader rather than an offer.
import { Modal } from '@growth-protocol-ai/alpha';When to use
Reach for a modal when the next step has to happen before anything else does and the page behind it should wait. It suits a short task that finishes in one screen — a confirmation, a rename, a single form — where taking the page away is proportionate to what is being asked.
When not to use
Do not put content in a modal that is long enough to deserve its own address, because a reader cannot bookmark or share one. Never open a modal over a modal: a two-step flow is one dialog with a back step. A message the reader only has to see does not need to take the page away at all.
width3 values
Three widths, and the choice is what the content needs rather than how important the modal is. At compact and wide the height is never set: the dialog is as tall as its content until it runs out of viewport, and then the body scrolls. Large is the exception and takes the full viewport in both directions.
| width | When to use |
|---|---|
compact | The default. A confirmation, a rename, a handful of fields — anything that reads as one question. |
wide | Room for two columns or a short table without cramping either. |
large | A working surface: a picker, a preview, an editor. At this width, ask first whether it should be a page. |
| State | When to use |
|---|---|
Open | The dialog sits over a dimmed backdrop with the page behind it inert. open, defaultOpen and onOpenChange are the whole state model: pass the first two to drive it, and read every dismissal back through the third. |
Closed | Nothing is in the DOM — the dialog is unmounted rather than hidden, so a closed modal cannot be reached by the keyboard, by find-in-page or by a screen reader. All there is to see is the trigger, which is yours. |
With icon | icon puts a drawing at the head of the row, at the icon set’s own size and in the text colour. It is decoration in the strict sense — the title is what names the dialog, and the glyph is hidden from assistive technology — so reach for one where a family of dialogs wants a constant shape, not to carry meaning the title should be carrying. |
With back step | onBack puts a chevron at the head of the row, before the glyph if there is one — whether a step that goes back also wants an icon is yours to decide. The chevron is a bare control like the close button, so backLabel is its accessible name and is not rendered as text. |
Without title | title is optional. Nothing is drawn in its place and the row keeps its shape, so the close button sits where it always does. Leave it out only where the surface around the dialog already says what this is — and pass aria-label when you do, because nothing else names it and one announced as “dialog” tells a screen-reader user nothing. |
With actions | actions sits between the title and the close button, for controls that act on the modal as a whole. They share the row with the title, so each one added is width the title loses — two or three is the most a compact dialog holds before the name starts truncating. |
Scrollable body | The head and the foot stay put and only the middle scrolls, so the title, the close and the actions are never scrolled away from. ModalBody notices it is scrollable and fades its top and bottom edges, so the fade shows only on the side that still has content behind it. |
Keep the head to controls that act on the modal as a whole — an export, a mode switch, an edit. Anything that answers the modal is a footer button, where the reader is already looking for it, and the head shares its row with the title, so two or three is the most it holds before the title starts losing width.
The title is the dialog’s accessible name, so a modal that shows one needs nothing else. Leave it out only where the surface around the dialog already says what it is, and then pass aria-label instead — a modal announced as "dialog" tells a screen-reader user nothing about what just took the page.
Escape closes, a click on the backdrop closes, and both report through onOpenChange — so a controlled modal that ignores the event cannot be dismissed. There is no way to opt out of Escape, and there should not be.
Focus moves into the dialog on open and returns to whatever opened it on close. While it is open, focus is trapped and the page behind is inert.
Both head controls are bare glyphs, so closeLabel and backLabel are accessible names and nothing else. Neither is rendered as text, so say where the control goes rather than what it looks like — "Back to plans" over "Left".
One dialog at a time. A modal over a modal leaves the reader with two Escapes to press and no way to tell what either will close — use onBack and two screens in one dialog.
Put the actions in a ModalFooter and nowhere else. A submit halfway up a scrolling body is a submit the reader will scroll past.
A confirmation is not a modal you assemble each time — reach for the ConfirmationModal module, which fixes this composition at one width so every confirmation in the product reads the same.
The head is the component’s, title included. Do not rebuild a title row inside the body to get a different one; if the head is wrong for the content, the content probably wants a page.
Two screens in one dialog
onBack puts a chevron at the head of the row and is how a multi-step flow stays one dialog rather than two. The title is what carries the step, so let it change between screens — that is what tells the reader where they are, and a chevron beside an unchanged title says only that there is somewhere to go back to.
| Owner | Prop | Type | Default | Notes |
|---|---|---|---|---|
Modal | width | "compact" | "wide" | "large" | "compact" | How much room the content needs. Height follows the content at compact and wide; large takes the viewport. |
Modal | title | string | — | The name, shown in the head. Omit it only where the surface around the dialog already says what it is, and pass aria-label so it is still named. |
Modal | icon | ReactNode | — | Glyph before the title, at the icon set’s own 20px. It sits after the back chevron where there is one; the two are independent. |
Modal | open | boolean | — | Controlled open state. Pair it with onOpenChange. |
Modal | defaultOpen | boolean | false | Initial open state when uncontrolled. |
Modal | onOpenChange | (open: boolean) => void | — | Every open and every dismissal — the close button, Escape and the backdrop all report here. |
Modal | onBack | () => void | — | Show a back chevron at the head of the row, before icon. |
Modal | backLabel | string | "Back" | Accessible name for the back chevron, which shows no text. |
Modal | closeLabel | string | "Close" | Accessible name for the close button. |
Modal | actions | ReactNode | — | Controls for the head, in a row between the title and the close button. The close button stays the modal’s own, so do not put a second one here. |
Modal | children | ReactNode | — | The screen below the head — normally a ModalBody and a ModalFooter. |
ModalBody | children | ReactNode | — | Everything between the head and the foot. |
ModalFooter | children | ReactNode | — | Buttons, which sit at their own width against the trailing edge. |