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">&times;</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

OnAttributeValues
.corn-modal-backdropdata-visiblepresent or absent
.corn-modaldata-visiblepresent or absent
.corn-modaldata-sizesm|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

OptionTypeDefaultDescription
titlestringundefinedModal heading text
contentstring|HTMLElementundefinedBody content. A string is shown as text, never parsed as HTML; pass an element for markup.
footerHTMLElementundefinedFooter element (e.g. buttons)
size'sm'|'md'|'lg'|'xl'|'fullscreen''md'Dialog width
closeablebooleantrueShow close button and allow backdrop/esc close
onOpenFunctionundefinedCalled when modal opens
onCloseFunctionundefinedCalled 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

PropTypeDefaultDescription
openbooleanrequiredWhether the modal is visible
onCloseFunctionrequiredCalled when modal should close
titlestringundefinedHeader title text
size'sm'|'md'|'lg'|'xl'|'fullscreen''md'
closeablebooleantrueShow close button & allow esc/backdrop close
classNamestringundefinedExtra class on the dialog element
childrenReact.ReactNoderequired

<Modal.Header> props

Title bar with an optional close button.

PropTypeDefaultDescription
childrenReact.ReactNodeundefinedTitle content.
onClose() => voidundefinedFired by the close button.
closeablebooleantrueRenders the close button.

<Modal.Body> props

Scrollable content region.

PropTypeDefaultDescription
childrenReact.ReactNodeundefinedBody content.

<Modal.Footer> props

Action bar, typically holding buttons.

PropTypeDefaultDescription
childrenReact.ReactNodeundefinedFooter 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:

  1. an element marked data-autofocus — on a wrapper, the first control inside it
  2. a field React’s autoFocus has already focused
  3. an element with a plain HTML autofocus attribute
  4. the first control in the content
  5. 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.