docs menu
Modal
A dialog over the page that keeps focus inside until it closes.
import { Modal } from "@meowerse/ui"; · source packages/ui/src/components/Modal.tsx
try it
working: loading the demo…
demo source
import { useId, useState } from "react";
import { Button, Checkbox, Field, Icon, Modal } from "@meowerse/ui";
// A rename dialog whose two hidden controls sit at the trap's EDGES (fix round 1: mid-order hidden
// controls left native Tab to skip them on its own, so a broken usable() never showed). "clear" is
// visibility:hidden and, while empty, the FIRST focusable thing in the panel; the "advanced" section
// is display:none and, while collapsed, the LAST — so wrapping (Shift+Tab from the true first, Tab
// from the true last) only lands correctly if Modal's usable() truly excludes both.
export default function Demo() {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [more, setMore] = useState(false);
const [saved, setSaved] = useState<string | null>(null);
const hintId = useId();
const empty = name.trim() === "";
const close = () => { setOpen(false); setMore(false); };
return (
<div className="demo">
<Button onClick={() => setOpen(true)}>rename chat</Button>
{saved !== null && <p role="status" className="demo__out">renamed to “{saved}”</p>}
<Modal open={open} onClose={close} title="rename chat">
<div className="demo-modal">
<div className="demo-modal__row">
<button type="button" aria-label="clear the name" className={empty ? "demo-modal__clear is-invisible" : "demo-modal__clear"} onClick={() => setName("")}>
<Icon name="x" />
</button>
<Field label="new name" value={name} onChange={(e) => setName(e.target.value)} autoComplete="off" data-autofocus />
</div>
{empty && <p id={hintId} className="mw-field__hint">type a name to save.</p>}
<div className="mw-confirm__actions">
<Button onClick={close}>cancel</Button>
<Button variant="primary" disabled={empty} aria-describedby={empty ? hintId : undefined}
onClick={() => { setSaved(name.trim()); setName(""); close(); }}>save</Button>
</div>
<Button variant="ghost" aria-expanded={more} aria-controls="demo-advanced" onClick={() => setMore((m) => !m)}>{more ? "fewer options" : "more options"}</Button>
<div id="demo-advanced" className={more ? "demo-modal__more" : "demo-modal__more is-collapsed"}>
<Checkbox label="tell the members" />
</div>
</div>
</Modal>
</div>
);
}
props
| prop | type | default | required |
|---|---|---|---|
open | boolean whether the dialog is open. | — | yes |
onClose | () => void called when Escape or the backdrop closes the dialog. | — | yes |
title | string the dialog's title, used to label it for assistive tech. | — | yes |
children | ReactNode the dialog's content. | — | yes |
className | string extra class names to append to the dialog panel. | — | no |
css custom properties
| variable | dark | light |
|---|---|---|
--c-line-strong | #404044 | #a9a8a2 |
--c-scrim | rgba(0, 0, 0, 0.6) | rgba(20, 20, 22, 0.4) |
--c-surface-raised | #1a1a1d | #f7f6f3 |
--fs-5 | 1.25rem | same |
--r-l | 8px | same |
--shadow-lg | 0 16px 40px rgb(0 0 0 / 0.35) (legacy alias) | — |
--sp-2 | 8px | same |
--sp-4 | 16px | same |
--sp-5 | 24px | same |
--z-sheet | 30 | same |
Classes: .mw-modal, .mw-modal-backdrop. Set any of these variables on an ancestor (or wrap a subtree in .mw-theme--dark / .mw-theme--light) to restyle just that part of a page.
accessibility
- role="dialog" with aria-modal, labelled by its title.
- Focus moves inside on open, cycles only through controls you can use (it skips disabled, hidden, display:none and visibility:hidden ones) and returns to the trigger on close.
- Escape and the backdrop close it; with two dialogs open, only the top one reacts.
- It has no close button of its own and doesn't lock page scroll yet (audit U-08): always give it a visible "cancel" or "close" button.
| key | does |
|---|---|
| Tab / Shift+Tab | cycles through the dialog's controls |
| Escape | closes it |
do and don't
do
- Put the main action last and a cancel button next to it.
don't
- Don't open a modal on page load.
- Don't put a whole page inside one.