Modal
A dialog overlay that blocks interaction with the page until dismissed.
import { ... } from '@corndev/ui-overlays' HTML
<div class="corn-modal-backdrop" data-visible>
<div class="corn-modal" data-size="md" data-visible role="dialog" aria-modal="true">
<div class="corn-modal-header">
<h2 class="corn-modal-title">Confirm</h2>
<button class="corn-modal-close" aria-label="Close">×</button>
</div>
<div class="corn-modal-body">Are you sure?</div>
<div class="corn-modal-footer">
<button class="corn-button" data-level="primary">Yes</button>
</div>
</div>
</div>
Classes
| Class |
|---|
.corn-modal |
.corn-modal-backdrop |
.corn-modal-header |
.corn-modal-title |
.corn-modal-close |
.corn-modal-body |
.corn-modal-footer |
.corn-modal-open |
Attributes
| On | Attribute | Values |
|---|---|---|
.corn-modal-backdrop | data-visible | present or absent |
.corn-modal | data-visible | present or absent |
.corn-modal | data-size | sm|md|lg|xl|fullscreen |
Vanilla JS
Required for behaviour — adds traps focus, makes the rest of the page inert, closes on Escape and backdrop click, and toggles the visible classes.
import { createModal } from '@corndev/ui-overlays';
import '@corndev/ui/theme';
const instance = createModal({ /* options */ });
Options
| Option | Type | Default | Description |
|---|---|---|---|
title | string | undefined | Modal heading text |
content | string|HTMLElement | undefined | Body content. A string is shown as text, never parsed as HTML; pass an element for markup. |
footer | HTMLElement | undefined | Footer element (e.g. buttons) |
size | 'sm'|'md'|'lg'|'xl'|'fullscreen' | 'md' | Dialog width |
closeable | boolean | true | Show close button and allow backdrop/esc close |
onOpen | Function | undefined | Called when modal opens |
onClose | Function | undefined | Called when modal closes |
React
Modal, ModalHeader, ModalBody, ModalFooter, ModalsProvider, modals — from @corndev/ui-overlays-react.
Visibility is controlled by open; body and footer go in as Modal.Body / Modal.Footer children rather than as props. For dialogs opened from outside React — the usual case for “are you sure?”, where the code asking is the code doing the deleting — mount <ModalsProvider> once at the root and call modals.openConfirmModal({ title, children, labels, confirmProps, onConfirm, onCancel }), modals.openModal(...), modals.closeModal(id) or modals.closeAll(). Calls made before the provider mounts are queued and replayed. openConfirmModal takes callbacks and returns the modal id rather than returning a promise, because the id is what closeModal needs. Cancelling, Escape, the backdrop and the close button are all one path, so onCancel runs however the dialog was dismissed — unless onDismiss is given, in which case Escape, the backdrop and the close button call that instead, for a question where walking away is not the same as “no”. Every dialog it renders is an ordinary <Modal>, so the focus trap, focus return and the inert page behind it are the same code, not a second implementation. modals.openContextModal({ modal, innerProps, title, size }) opens a dialog registered by name on <ModalsProvider modals={{ gitDiff: GitDiffModal }}>. A registry entry is a component, so it can be lazy(() => import(...)) and stay out of the bundle until opened — which children: <GitDiffModal /> cannot do, since that imports the component at every call site. The provider wraps the entry in its own <Suspense>, so apps must not add one around <ModalsProvider>: that would enclose its children too, and React hides suspended content with display:none, blanking the app out behind the dialog while the chunk loads. Pass loading for a placeholder in the body. It also lets non-React code name a dialog without importing React. The registered component receives innerProps as an object rather than spread, so a key called close or id cannot shadow the id and close passed alongside it. An unregistered name logs an error and opens nothing, rather than showing an empty dialog.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | required | Whether the modal is visible |
onClose | Function | required | Called when modal should close |
title | string | undefined | Header title text |
size | 'sm'|'md'|'lg'|'xl'|'fullscreen' | 'md' | |
closeable | boolean | true | Show close button & allow esc/backdrop close |
className | string | undefined | Extra class on the dialog element |
children | React.ReactNode | required |
<Modal.Header> props
Title bar with an optional close button.
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | undefined | Title content. |
onClose | () => void | undefined | Fired by the close button. |
closeable | boolean | true | Renders the close button. |
<Modal.Body> props
Scrollable content region.
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | undefined | Body content. |
<Modal.Footer> props
Action bar, typically holding buttons.
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | undefined | Footer content. |
React usage
import { useState } from 'react';
import { Modal } from '@corndev/ui-overlays-react';
import '@corndev/ui-overlays/styles';
const [open, setOpen] = useState(false);
<button onClick={() => setOpen(true)}>Open modal</button>
<Modal open={open} onClose={() => setOpen(false)} title="Confirm action">
<Modal.Body>Are you sure you want to continue?</Modal.Body>
<Modal.Footer>
<button onClick={() => setOpen(false)}>Cancel</button>
<button onClick={() => setOpen(false)}>Confirm</button>
</Modal.Footer>
</Modal>
Sizes
<Modal size="sm"> ...</Modal>
<Modal size="md"> ...</Modal>
<Modal size="lg"> ...</Modal>
<Modal size="xl"> ...</Modal>
<Modal size="fullscreen"> ...</Modal>
Non-closeable
<Modal open={open} onClose={() => setOpen(false)} closeable={false}>
<Modal.Body>You must complete this step before continuing.</Modal.Body>
<Modal.Footer>
<button onClick={complete}>Continue</button>
</Modal.Footer>
</Modal>
Sub-components
<Modal.Header>Custom header</Modal.Header>
<Modal.Body>Body content</Modal.Body>
<Modal.Footer>Footer content</Modal.Footer>
What it does for accessibility
While a modal is open the rest of the page is inert: it leaves the
accessibility tree, stops responding to pointers and drops out of the focus
order. A focus trap alone only holds the keyboard — a screen reader’s virtual
cursor and touch exploration would still reach the page behind the dialog.
Opening a drawer from a modal makes the modal inert too, and closing the drawer
gives it back. Anything you had already marked inert yourself stays that way
after the dialog closes.
Where focus goes
When a modal opens, focus goes to the first of these that exists:
- an element marked
data-autofocus— on a wrapper, the first control inside it - a field React’s
autoFocushas already focused - an element with a plain HTML
autofocusattribute - the first control in the content
- the close button, and only when there is nothing else
The close button is deliberately last. It renders first, in the header, so
“the first focusable element” used to mean ✕ — which put the reader one Enter
away from closing what they had just opened, and overrode any autoFocus a form
asked for.
<Modal open={open} onClose={close} title="Rename">
<input data-autofocus defaultValue={name} />
</Modal>
data-autofocus on an element, or on a wrapper around one, names it outright. If the content is lazy — a context modal whose chunk is still
loading — focus moves onto it once it arrives, unless the reader has already
moved focus themselves.
On close, focus goes back to whatever opened the modal, however it closed.
Stacking
Escape closes only the modal on top. Every open dialog listens for keys, so this matters: a “Discard changes?” confirm on top of a form must not take the form, and its unsaved work, with it. Each open dialog is also named by its own title, and the page stays scroll-locked until the last one closes.
Confirmations, from anywhere
The code that needs to ask “are you sure?” is usually the code doing the
deleting — a store action, a command handler, a keyboard shortcut — not a
component that could hold open state. Mount <ModalsProvider> once at the
root:
import { ModalsProvider } from '@corndev/ui-overlays-react';
<ModalsProvider>
<App />
</ModalsProvider>
Then ask from wherever the work happens:
import { modals } from '@corndev/ui-overlays-react';
export function deleteColumn(id) {
modals.openConfirmModal({
title: 'Delete column',
children: 'Every value in it goes too. This cannot be undone.',
labels: { confirm: 'Delete', cancel: 'Keep it' },
confirmProps: { level: 'danger' },
onConfirm: () => store.dispatch(columnDeleted(id)),
});
}
modals.openModal(options) opens a plain dialog, modals.closeModal(id) closes
one and modals.closeAll() clears the stack. Calls made before the provider
mounts are queued and replayed in order.
Why it isn’t a promise
openConfirmModal returns the modal’s id, not a promise, and takes
onConfirm / onCancel. The id is what closeModal needs — a promise would
carry the answer but not the handle, so a confirmation left open by a route
change could never be dismissed from elsewhere. Wrapping one is three lines if
you want it:
const confirm = options =>
new Promise(resolve => {
modals.openConfirmModal({
...options,
onConfirm: () => resolve(true),
onCancel: () => resolve(false),
});
});
Cancelling is one path
onCancel runs whether the dialog was cancelled with the button, dismissed with
Escape, clicked away on the backdrop, or closed with the ×. A hand-rolled
confirm usually wires the button and forgets the other three, so the caller
never learns the answer was no. Pass closeable: false to require an explicit
choice.
Unless walking away is its own answer
Some questions have three answers. “Discard your changes?” — Discard, Keep them,
or the reader pressed Escape because they are not done editing. Pass onDismiss
and Escape, the backdrop and × call it instead of onCancel, which then means
only the button:
modals.openConfirmModal({
title: 'Discard changes?',
labels: { confirm: 'Discard', cancel: 'Save them' },
onConfirm: discard,
onCancel: save,
onDismiss: () => {}, // keep editing
});
Without onDismiss nothing changes: every way out still runs onCancel.
What it inherits
Every dialog here is an ordinary Modal — the component documented above — so focus is trapped inside it,
focus returns to whatever opened it, and the page behind it is inert for
pointers and screen readers. Opening a second dialog stacks it: the one beneath
becomes inert and is handed back when the top one closes.
Dialogs registered by name
openModal takes the body as children, which means every call site imports
the component it wants to show. openContextModal takes a name instead,
looked up in a registry on the provider:
import { lazy } from 'react';
import { ModalsProvider, modals } from '@corndev/ui-overlays-react';
const GitDiffModal = lazy(() => import('./GitDiffModal.jsx'));
<ModalsProvider modals={{ gitDiff: GitDiffModal }}>
<App />
</ModalsProvider>;
// Anywhere — a saga, a store middleware, a keyboard handler.
modals.openContextModal({
modal: 'gitDiff',
title: 'Changes on this branch',
size: 'lg',
innerProps: { branch: 'feature/login-form' },
});
The registered component is rendered with three props:
export default function GitDiffModal({ innerProps, id, close }) {
return <Diff branch={innerProps.branch} onApply={close} />;
}
Why a registry and not just children
Code splitting survives. An entry is a component, not an element, so it
can be lazy(() => import(…)) and whatever it pulls in — an editor, a diff
viewer — stays out of the main bundle until someone opens it. Writing
children: <GitDiffModal /> imports the component at every call site, so it
ships whether or not the dialog is ever opened.
The provider puts a <Suspense> around the entry itself, so you do not need one
in your app — and should not reach for one. A boundary wrapping <ModalsProvider>
also encloses its children, and React hides suspended content with
display: none rather than leaving it up, so the whole app would blank out
behind the dialog until the chunk arrived. Scoped to the body, the dialog shell
and its title appear immediately and only the contents wait. Pass loading to
put something there in the meantime:
modals.openContextModal({
modal: 'gitDiff',
title: 'Changes on this branch',
loading: <Spinner />,
innerProps: { branch: 'feature/login-form' },
});
The caller stops needing the component. Non-React code can ask for “the
branch manager” by name without importing React. That is the argument
openModal already accepted for opening a dialog, applied to which dialog.
innerProps is not spread
Your data arrives as one innerProps object rather than as loose props,
alongside id and close. A dialog whose own data happens to have a key called
close or id would otherwise shadow the two the provider passes, and closing
it would quietly stop working.
An unregistered name opens nothing
A typo in modal: logs an error naming what is registered, and no dialog
appears. The alternative — an empty dialog with a close button — reads as a
broken app and says nothing about what went wrong. It is an error rather than a
throw because a missing dialog should not take the app down, and it is logged in
production because the call can come from a saga.