---
name: overlays
load-when: picking or debugging an overlay surface — modal-ui vs drawer-ui vs popover-ui vs inline disclosure
load-size: ~1.5k tokens
required-for: [screen-composition — overlay picks]
---

# Overlay surfaces — modal-ui vs drawer-ui vs popover-ui vs inline

Four overlay-shaped choices, in order of interruption. This is the decision layer, not a prop
dump — verify exact props/events with `lookup_component` and compose full surfaces from the
pattern-catalog entries named below.

(Claims re-verified against the kit's live source 2026-08-26; re-verify on a MINOR cut.)

## The decision rule

| Need | Use | Why |
| --- | --- | --- |
| Blocking, focus-trapped, short (≤2 fields or one decision), interrupts the task | `modal-ui` | Native `<dialog>` (`showModal()`), centered, `[size]` presets (sm=24rem/md=32rem/lg=48rem), suppresses everything behind it unless `[permanent]` |
| Edge-anchored, keeps page context, multi-field create/edit or list-detail inspection | `drawer-ui` | Same native-`<dialog>` focus trap as modal-ui but anchored to a `[side]` (left/right/top/bottom) — the list/page stays scrollable behind it conceptually even though the dialog still traps focus while open |
| Reversible micro-decision anchored to a trigger, non-modal, page stays interactive | `popover-ui` | Popover API (`showPopover()`/`hidePopover()`) + CSS Anchor Positioning — no focus trap, no backdrop blocking the rest of the page |
| Read-only hover/focus hint, no interaction | `tooltip-ui` | `follows="trigger"` (hover/focus label) or `follows="pointer"` (chart-hover tracking); never for anything the user must act on |
| Action list from a trigger click | `menu-ui` | The specialized popover: `role=menu` + roving tabindex over `menu-item-ui` children, Popover API under the hood |
| Action list from a right-click / long-press | `context-menu-ui` | Same item shape as menu-ui (`menu-item-ui` children) but trigger-surface is right-click/long-press, not a button |

Both `modal-ui` and `drawer-ui` are built on the same native-`<dialog>` primitives (portal,
`::backdrop`, focus-trap, Escape-dismiss) — the only real difference is anchoring (centered vs
edge) and, correspondingly, the weight of interruption implied. `modal-ui`'s own yaml states
this directly: "same portal / backdrop / focus-trap / Escape-dismiss primitives as `<drawer-ui>`,
but anchored to the viewport center with size presets" (`modal.yaml`).

**modal-ui is not the Cmd+K palette.** `<admin-command>` is a bespoke shell-tier component under
`<admin-shell>`, not a modal-ui composition, even though the visual reads overlay-like
(`modal.yaml` a2ui rule).

**Nest nothing dialog-shaped inside a popover.** `popover-ui`'s own contract rules this out
explicitly: "Do NOT nest `<modal-ui>` or `<drawer-ui>` inside `slot="content"`; popovers are
non-modal anchored surfaces, not dialog hosts. Stacking dialog surfaces inside a popover breaks
focus management" (`popover.yaml`).

**Popover placement convention (ADR-0034, from `popover.yaml`):** default `bottom` centers under
the trigger — right for wide pickers (calendar, color, filter forms). `bottom-start` for
trigger-width menus (action lists, listboxes). `bottom-end` only when the trigger sits at a
container's right edge by construction. `top-*` when the trigger sits low in the viewport.
`[offset]` (default 4px) sets the anchor gap.

**`[trigger="hover"]` is for non-essential disclosure only** — never for a popover carrying
inputs, destructive actions, or anything requiring keyboard interaction (`popover.yaml`). Default
`[trigger="click"]` for anything interactive.

## Gotchas (verified from source)

- **The mobile-nav-drawer is ALWAYS in the DOM (gh#2031, ADR-0090).** `admin-shell`,
  `chat-shell`, and `editor-shell` each stamp a persistent `<drawer-ui data-mobile-nav-drawer>` —
  it is not conditionally created; only its visibility is container-query-driven below the
  shell's mobile breakpoint (768px for admin-shell/chat-shell, 1024px for editor-shell's leading
  sidebar). A consumer selector or test that assumes exactly one `drawer-ui`/`dialog` on the page
  will match this one too — scope past it explicitly, e.g. `:not([data-mobile-nav-drawer])`.
  This shipped silently in 0.8.52 and broke a real downstream consumer before the docs caught up
  (`packages/web-modules/CHANGELOG.md`).
- **Never set `.innerHTML` on a modal-ui/drawer-ui host.** Both wipe the stamped `<dialog>` part
  and the authored header/section/footer skeleton; mutate a stable inner element inside a
  persistent `<section>` instead — same rule, same wording, in both yamls.
- **drawer-ui direct children are structurally enforced.** Must be `<header>`, one-or-more
  `<section>`, `<footer>`, or an explicit `[slot="header|body|footer"]` element — bare
  `<col-ui>`/`<row-ui>`/`<div>` at the top level must be wrapped in a `<section>`.
  `scripts/audit/audit-drawer-structure.mjs` enforces this; bypassing it loses `--drawer-inset`
  and teaches the gen-UI corpus the wrong pattern.
- **drawer-ui's `close` event carries a typed reason** (`detail.reason` ∈ `escape` | `backdrop` |
  `close-button` | `programmatic`) — modal-ui's `close` event does not distinguish dismiss paths.
  If the surface needs to know *why* it closed (e.g. skip a confirm-discard on Escape but not on
  backdrop), that's drawer-ui-only today.
- **popover-ui uses `popover="manual"`, not the browser's `popover="auto"` light-dismiss.** The
  component wires its own outside-click + Escape handling (deferred one frame past the opening
  click so the trigger click doesn't self-dismiss). Known Safari quirks at the supported floor
  (light-dismiss broken 17.0–18.2; virtual-keyboard-on-input persists past 18.3) are tracked in
  `.claude/docs/BROWSER-COMPAT.md` §3a — don't assume vanilla Popover-API dismiss semantics.
  `[matchWidth]` sizes the panel to the trigger's width instead of content-width (opt-in).
- **tooltip-ui is two unrelated modes behind one tag.** `follows="trigger"` (default) is a plain
  hover/focus label; `follows="pointer"` subscribes to `chart-hover`/`chart-leave` events from a
  `[for]`-referenced `chart-ui`/`heatmap-ui` and renders a data-viz annotation that tracks the
  cursor — `[for]` is required in pointer mode or the tooltip renders nothing.
- **menu-ui vs context-menu-ui have the same item shape, different trigger.** Both consume
  `menu-item-ui` children and fire an equivalent select event, but menu-ui requires an explicit
  `slot="trigger"` focusable child while context-menu-ui activates on right-click or a
  touch long-press (`[duration]`, default 500ms) — don't reach for context-menu-ui just because
  a design shows a kebab-button dropdown; that's menu-ui.
- **Sidebar/persistent nav is never menu-ui or context-menu-ui** — `<nav-ui>` + `<nav-item-ui>`
  own persistent navigation; menu-ui/context-menu-ui are for Popover-API dropdowns only
  (`component-model.md`, `shell-admin.md`).

## Deeper material

- Full compositions: pattern-catalog's **Alert Dialog** (blocking destructive confirm over
  modal-ui, WAI alertdialog), **Inline Dialog** (anchored non-modal popover mini-form),
  **Record Detail Drawer** (side drawer + tabs over a list), and **Form Drawer** (under
  forms-input — side drawer + sticky footer for create/edit) — search `pattern-catalog`'s
  pattern index by name before hand-composing any of these shapes.
- Exact props/events/slots: `mcp__a2ui__lookup_component` — this file states the decision, not
  the current prop list.
- Shell-scoped mobile-nav mechanics beyond the gotcha above: `shell-admin.md` / `shell-chat.md` /
  `shell-editor.md`.
