Skip to contentAsheeUIAsheeUI

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" with aria-modal="true" and tabIndex={-1}, the aria-modal is omitted when there is no onClose, 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, or aria-labelledby naming 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 onClose has 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 while closeOnEscape is 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.