Modal
A dialog in the middle of the viewport, for a decision the reader should not walk around.
A modal takes the screen until the reader has answered it. Reach for it when there is a decision with two outcomes, delete or keep, accept or decline, name or cancel, and not for anything the reader should be able to keep working beside: details and filters belong in a Drawer, which leaves the page in view. The dialog sits in the middle of the viewport, as wide as its size allows, and the page behind it stays visible, dimmed and still.
On the platform the same props present the platform's own modal, with the platform's way out standing in for Escape. See React Native.
Usage
import { Button, Modal } from "@asheeui/web";
import { useState } from "react";
export function DeleteInvoice() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button onClick={() => setIsOpen(true)}>Delete invoice</Button>
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
size="sm"
aria-labelledby="delete-title">
<div className="p-6">
<h2 id="delete-title">Delete invoice INV-1041?</h2>
<p>The invoice is removed for everyone on the account.</p>
<Button color="danger" onClick={() => setIsOpen(false)}>
Delete invoice
</Button>
</div>
</Modal>
</>
);
}
children is the whole surface: the component supplies the box, the backdrop, the focus trap and
its own close control, not a header, a body or a footer, and the content box has no padding of its
own. Padding and actions are yours, which is why every example here opens its content with a
padded stack. The close control sits in the top-right corner, is first in the tab order, and is
named by closeLabel, "Close dialog" unless you state otherwise.
There is no title prop. The dialog is named by the consumer, either with aria-label or
with aria-labelledby pointing at a heading inside it, the component supplies the role and
the trap, and cannot know what the dialog is about.
Position and size
Two options decide the shape. size is a maximum width on the modal's own scale, sm 24rem,
md 32rem, lg 42rem, xl 56rem, and full the viewport less a two-rem gutter, and
position is where the dialog sits vertically: center for a decision, top for a short
message that should not cover the page, bottom where the platform's sheets live. The
horizontal axis is always centred, so position moves the dialog up and down only.
width and height are the escape hatches, and neither is the size scale in another unit.
width replaces the resolved size outright and takes any CSS width ("500px", "80%");
height states an inline height on the content box. The height is still capped at 90% of the
viewport, and the box scrolls inside that cap, so a long form never makes the dialog taller
than the screen. A stated width is still capped at the viewport, so a wide dialog shrinks on a
narrow screen instead of overflowing a page whose scroll is locked.
radius rounds the corners and resolves from the application's defaultRadius, the
framework's own default is xs, so a dialog is boxy until the product says otherwise, and
falls back to lg only when the application states no default at all. A product that rounds
its buttons rounds its dialogs with them. animated (registered true) is what makes the box
and the backdrop appear and disappear; a closing dialog stays mounted for the 200 ms exit
before it leaves the DOM.
Dismissal
Two switches, closeOnBackdropClick and closeOnEscape, both registered true. They are not
the same gesture. Escape is the reader asking to cancel; a backdrop press is a click
that missed as often as it is a decision, so a dialog that holds typed input usually turns the
first off and keeps the second.
The backdrop is aria-hidden and out of the tab order, so it carries no keyboard route:
turning closeOnBackdropClick off takes away a pointer gesture and nothing else, because the
close control the component draws stays, and so does Escape. Escape is
answered by the topmost layer through a shared stack, so a modal opened over a drawer closes only
itself, and it works wherever focus is inside the dialog.
Both switches call onClose. It is optional in the type, but a modal without one is presented
non-modally, no scroll lock, no focus trap, no aria-modal, and logs a warning, because a layer
that cannot close must not hold the page. Wire it to the state that controls isOpen.
Props
The registered defaults are size md, position center, animated true, and both
dismissal switches true; state any of them once under components.modal and every modal in
the product follows. radius is deliberately not registered: it resolves from the
application's defaultRadius, which the framework states as xs, and takes the modal's own
lg only when no application default is present. Rounding is therefore a product decision
rather than a dialog's.
width, height, isOpen, onClose, closeLabel, children, overlay and content are
instance-only: how large one dialog is, what it says and what its close control is called are
statements about one screen, not about the design. overlay.className is merged with the
backdrop's classes and content.className with the content box's, so each adds or replaces
individual utilities and keeps the rest of the surface.
Accessibility
- The content box is
role="dialog"witharia-modal="true"andtabIndex={-1}, thearia-modalis omitted when there is noonClose, because the modal is then presented non-modally. The backdrop is decorative, so the reader meets the dialog once rather than a backdrop and then a dialog. - The name is the consumer's. Pass
aria-label, oraria-labelledbynaming a heading inside the dialog; a dialog with no name is announced as "dialog" and nothing else. - Focus moves to the dialog box when it opens, and is trapped inside it while it is open; a modal
with no
onClosehas no trap, because it is not allowed to hold a page it cannot release. Closing returns focus to whatever held it before. The built-in close control is the first tab stop, so one Tab reaches the way out. Escape closes it whilecloseOnEscapeis on, and only the topmost layer answers that press. - Everything the reader needs belongs inside the dialog: while the trap is on, a control left behind it cannot be reached.
Focus, the scroll lock, the portal and the two dismissal switches are the shared overlay contract; this page states only what the modal adds to it.