---
name: dialog-and-popover
description: "Audits and fixes modal dialogs, popovers and their trigger buttons. Runs a read-only checker for div-based modals, unnamed dialogs, tabindex on dialog, broken popovertarget or commandfor targets, invalid command values, empty close buttons and cancel buttons blocked by required fields, then applies rules for choosing dialog, auto, manual or hint, focus, Esc, scroll lock and animation. Use for modal, dialog, popup, overlay, popover, lightbox, drawer, showModal or focus trap work. Do NOT use for form fields (html-forms), landmarks (semantic-html), consent wording, tabs or comboboxes."
license: CC-BY-4.0
metadata:
  author: Afeez Adebayo
  author_url: https://afeezadebayo.com
  author_linkedin: https://www.linkedin.com/in/afeez-adebayo/
  author_x: https://x.com/za_icon
  source: https://afeezadebayo.com/skills-md/frontend-development/html-semantics/dialog-and-popover/
  category: frontend-development
  subcategory: html-semantics
  version: "1.0.0"
  created: "2026-10-24"
  updated: "2026-10-24"
---

# Dialogs and Popovers: Native Modals, Overlays and Focus

An agent audits and fixes overlay markup in this order: run a read-only checker, choose the right native primitive for each overlay with a decision table, apply fixed rules for naming, focus, Esc, scrolling and motion, then edit in small approved batches and test the behaviour the checker cannot see.

It works on plain HTML and on the rendered output of any framework or UI library. Source facts sit in `references/appendix.md` and code in `references/patterns.md`.

## When to use this skill, and when not to

Use it to audit, fix or build modal dialogs, confirm boxes, drawers, lightboxes, popovers, filter panels, toasts and their trigger buttons, or to replace a hand-built modal and focus trap with the native elements.

| Request | Use instead |
|---|---|
| Fields, labels and validation inside a dialog | `html-forms.md` |
| Landmarks, headings, links and buttons outside overlays | `semantic-html.md` |
| Cookie, consent or age-gate wording and whether dismissing counts as consent | A person with legal responsibility. This skill marks up the dialog only |
| Tabs, comboboxes, date pickers, carousels, tree views | A component accessibility skill. This skill covers dialog and popover only |
| Visual design of the overlay | Out of scope |

## Guardrails

1. **Propose, then change.** Report and plan first. Edit only after the user approves a batch, on a branch under version control. Never push, merge or deploy.
2. **Never trigger real actions.** Confirm dialogs guard deletes, payments and sends. Test only in a local or test environment with fake data. The agent never clicks a live "Delete", "Pay" or "Send".
3. **The dialog is not the authority.** A confirm button is user experience. The server must authorise every destructive action, and the report says so if it does not.
4. **Personal data.** Dialogs show names, emails, addresses and account details. Use invented test data such as `test@example.com`, never paste entered or displayed values into reports, and never store them.
5. **Batches stay small.** At most 3 overlays or 30 attribute changes per batch (defaults), each shown as a diff.
6. **Nothing is invented.** Thresholds are labelled defaults. Source facts carry a date. Version-dependent facts carry **verify before relying on this**.
7. **Not legal advice.** Consent, cookie and age dialogs carry legal duties. The agent routes wording and the dismiss-equals-consent question to a person.
8. **Refuses to:** disable Esc without a documented data-loss reason and a guard (step 3), remove the only visible close control, build a JavaScript focus trap where a native `dialog` fits, put `role="dialog"` or `aria-modal` on a popover, or fake modality with `aria-hidden` on the page behind.

## Inputs the agent must have before starting

Ask for what is missing in one message and wait. Do not proceed on assumptions.

| # | Input | What to ask if missing |
|---|---|---|
| 1 | Target | "Which pages, templates or components? File paths, or rendered HTML saved with each overlay in its closed and open state." |
| 2 | Stack | "Plain HTML, a framework, or a UI library that renders overlays (Radix, Headless UI, MUI, Bootstrap)?" |
| 3 | Browsers | "Which browsers and versions must work?" |
| 4 | Purpose | "What is each overlay for: confirm, form, information, menu, toast, hover hint, navigation?" |
| 5 | Data loss | "Does closing any overlay lose typed or unsaved data?" |
| 6 | Test setup | "Is there a test environment where I may click through with fake data, and can you run Python 3?" |
| 7 | Dependants | "Do tests, analytics or CSS read the ids, classes or roles I might change?" |

If input 3 is unknown, the agent uses only `dialog` with `showModal()` (MDN: widely available since March 2022) and JavaScript for everything else, and lists `command`, `closedby` and `popover="hint"` as options pending an answer. Static checks cannot see overlays that scripts build at run time. If input 1 gives only source for such a component, the agent works from the rendered DOM or says so.

## Workflow

### Step 0: Confirm scope

Confirm inputs 1 to 7 and the branch name. Tell the user the plan: checker, primitive choice, rules, batches, behaviour tests, report. Gate 1: wait for a yes.

### Step 1: Run the checker

Run `python3 scripts/dialog_check.py page.html`, pipe it through `head -60` and read the output only. Do not read the script. It reads files, writes nothing, exits 1 on any error and prints `file:line level code message`. Keep the output as the baseline.

| Code | Level | Fix |
|---|---|---|
| `custom-dialog` | warn | Step 2, then step 3 |
| `tabindex-on-dialog` | error | Delete it. MDN: never set `tabindex` on `dialog` |
| `open-attribute` | warn | Step 3.6 |
| `closedby-invalid` | error | Use `any`, `closerequest` or `none` (step 3.5) |
| `dialog-no-name`, `labelledby-missing-target` | error | Step 3.1 |
| `multiple-autofocus` | warn | Step 3.2 |
| `no-close-control` | warn | Step 3.3. Ignore if script adds one and the test in step 10 passes |
| `empty-button` | error | Give the button text or `aria-label` |
| `cancel-blocked-by-required` | warn | Add `formnovalidate` (step 3.4) |
| `popover-value` | warn | Step 4.1 |
| `invoker-not-button`, `invoker-target-missing`, `invoker-target-wrong` | error | Step 4.2 |
| `command-invalid`, `command-target-wrong` | error | Step 5 |
| `manual-aria-expanded` | warn | Step 4.3 |

The checker reads static markup only. It cannot see focus, scripts, CSS or overlays created at run time, so a clean result proves nothing about behaviour. Step 10 does.

### Step 2: Choose the primitive for each overlay

Apply the first matching row.

| Overlay | Use |
|---|---|
| Must be answered before the user continues (confirm, required form, blocking error) | Modal `dialog`, opened with `showModal()` or `command="show-modal"` |
| Panel the user may ignore while using the page (chat, inspector) | Non-modal `dialog`, opened with `show()` |
| Short-lived panel anchored to a button, dismissed by clicking away (filters, share menu, options) | `popover` (auto) |
| Panel that must stay open until the user or code closes it (toast, status) | `popover="manual"` |
| Hover or focus preview | `popover="hint"`, never the only home of important information. Verify browser support |
| A popover with a title and its own controls | `dialog` with the `popover` attribute, so it has a role and a name (MDN lists this pattern) |
| Content that expands in place | Not an overlay. Use `details` and `summary`, outside this skill |
| Site navigation menu | A disclosure that reveals a list of links. WebAIM Million 2026 found 22% of `role="menu"` elements had barriers, so reserve `role="menu"` for application action menus that implement arrow keys |

A `dialog` that traps the user without a reason is a defect, and a popover used where the user must decide is also one. Ask input 4 again if the row is unclear.

### Step 3: Modal dialog rules

Read `references/patterns.md` once, only when writing or rewriting markup, CSS or script (steps 3, 6 and 7).

1. **Name.** Every dialog has `aria-labelledby` pointing to a visible heading, or `aria-label`. Use `aria-describedby` only for short plain text. Do not describe structured content (W3C APG).
2. **Initial focus.** Put `autofocus` on the control named below. With no `autofocus`, `showModal()` focuses the first focusable element (MDN). Use one `autofocus` per dialog.

| Dialog content | Focus on open |
|---|---|
| Irreversible action | The least destructive button, such as "Keep account" |
| Informs or continues a process | The most-used button |
| Long text, a list or a table | A static heading with `tabindex="-1"`, with no `aria-describedby` |
| A form | The first field |

3. **Close control.** At least one visible close button in the tab order inside the dialog (APG: strongly recommended). Icon-only buttons need an accessible name.
4. **Forms.** Use `form method="dialog"` so a button closes the dialog and sets `returnValue`. A cancel button needs `formnovalidate`, because a required empty field blocks a submit button from closing the dialog (MDN). Read `returnValue` in the `close` event. Field rules go to `html-forms.md`.
5. **Esc and outside click.** Esc closes a modal (MDN) through the `cancel` event. `closedby="closerequest"` is the default for modals. Use `closedby="any"` only if clicking outside should close it, and **verify before relying on this** in the target browsers. If closing loses data (input 5), call `preventDefault()` on `cancel` while the form is dirty and ask first. Never remove Esc for convenience.
6. **Open it with the methods.** `showModal()` or `command="show-modal"` makes the rest of the page inert and exposes `aria-modal="true"` (MDN). The `open` attribute opens a non-modal dialog, so it is wrong for a modal. Do not add `aria-hidden` to the page or hand-written focus traps.
7. **Return focus.** On close, focus returns to the control that opened it, unless that control no longer exists, and then to a sensible target such as the list heading (APG). Test it (step 10). Do not assume it.
8. **Known bug.** `autofocus` on a slotted element inside a dialog in shadow DOM may not take focus (Mozilla Bugzilla 2050985, unconfirmed). If the overlay lives in a web component, test focus in Firefox and Safari.

### Step 4: Popover rules

1. **Kind.** Plain `popover` is `auto`: it closes on outside click and Esc. `manual` has no light dismiss and can stack with others. `hint` closes other hints only. A value outside `auto`, `manual` and `hint` is a defect.
2. **Invokers.** The trigger is a `button` with `popovertarget="id"` (and `popovertargetaction` of `show`, `hide` or `toggle` where the default toggle is not wanted), or `commandfor`. The id must exist and carry the `popover` attribute. A link is not an invoker.
3. **State.** The invoker gets an implicit `aria-expanded` (MDN). Do not add one by hand.
4. **Behaviour.** A popover is not modal and does not trap focus. Esc returns focus to the invoker (MDN). Keep controls inside reachable by Tab in reading order.
5. **Events.** Use `beforetoggle` (cancellable) and `toggle` for logic, and check `:popover-open` before calling `showPopover()` or `hidePopover()`.

### Step 5: Declarative or JavaScript

1. If input 3 includes only browsers with `command` and `commandfor` (InfoQ, 28 January 2026: Chrome 135, Firefox 144, Safari 26.2, so **verify before relying on this**), use them with built-in commands only: `show-modal`, `close`, `request-close`, `show-popover`, `hide-popover`, `toggle-popover`.
2. Otherwise use `popovertarget` for popovers and `showModal()` in script for dialogs. Use one approach per project.
3. Custom commands must start with `--` and need a `command` event listener. The checker accepts them. Prefer built-ins.

### Step 6: The page behind

1. A modal does not stop the page scrolling (whatwg/html issue 7732, open). Apply the CSS in `references/patterns.md`.
2. `overflow: hidden` on the body fails on iOS Safari (OpenReplay, 30 January 2026). If iOS support matters, use the fixed-body method in the same reference and test on a real device.
3. Cap the dialog with `max-height` and `overflow-y: auto` so a tall dialog stays reachable at 320 CSS pixels (WCAG 1.4.10 Reflow).

### Step 7: Motion

Animate entry with `@starting-style` and animate `display` and `overlay` with `transition-behavior: allow-discrete` (MDN). The `::backdrop` is removed at once on close, so keyframes cannot fade it out. Respect `prefers-reduced-motion` (this skill's rule). Snippet in `references/patterns.md`.

### Step 8: Libraries

If a UI library renders the overlay, run the checker on the rendered HTML and apply steps 3 and 4 as tests, not as a rewrite. Do not replace a working library component with native elements unless the user asks. Report any library behaviour that fails step 10.

### Step 9: Plan and apply in batches

1. Turn the checker output and decisions into a change list: file and line, current markup, new markup, rule (step number).
2. Search for dependants (commands below). Ids, classes and roles that tests or scripts read stay unless the plan updates them.
3. Group under guardrail 5. Gate 2: show each batch's diff and wait for a yes. Edit in place. Do not echo unchanged markup back.

### Step 10: Verify

1. Re-run `dialog_check.py`. Pass: 0 errors on changed files and no new warnings.
2. Keyboard, in the test environment. Open each overlay with the keyboard. Tab and Shift+Tab stay inside a modal and cycle. Esc closes it (unless step 3.5 guards it). Focus returns to the opener.
3. Modality. In the console, with the dialog open: `[!!document.querySelector('dialog:modal'), document.activeElement.closest('dialog') !== null]`. Pass: `[true, true]` for a modal. Clicking the page behind does nothing.
4. Screen reader or accessibility tree. The dialog announces its role and name on open. Popover invokers report expanded and collapsed.
5. Reflow. At 320 CSS pixels wide and 200% zoom, every control is reachable and nothing is cut off.
6. Scroll. With the modal open, the page behind does not move on desktop, and on iOS if it was in scope.
7. Motion. With reduced motion on, no transition runs.
8. Server. The destructive action is rejected without server authorisation (guardrail 3), tested with fake data.

### Step 11: Report

Use the report template. State what was changed, which behaviours were tested and by whom, what was not tested (browsers, iOS, screen readers), library failures and legal wording routed to a person.

## Worked example

The page is invented. The checker output is real: it came from running the script in this skill on this file.

**Input.** An account settings page. Plain HTML. Target browsers: current Chrome, Firefox and Safari. Closing the edit-profile overlay loses typed data. The server authorises deletes.

**Step 1, checker (before):**

```
page_before.html:4  warn   manual-aria-expanded  aria-expanded on a popovertarget button: MDN says the relationship is implicit
page_before.html:5  error  invoker-not-button  popovertarget on <a>: use a button
page_before.html:6  error  invoker-target-missing  target id 'missing-panel' not found in this file
page_before.html:7  error  command-target-wrong  show-modal needs a dialog, target is <div>
page_before.html:8  error  command-invalid  command='open' is not a built-in command or a --custom name
page_before.html:9  warn   popover-value  popover='sticky' is not auto, manual or hint
page_before.html:10  error  dialog-no-name  dialog has no aria-labelledby or aria-label
page_before.html:10  warn   custom-dialog  <div role=dialog> instead of native dialog: focus handling, inert and Esc must be built by hand
page_before.html:11  error  closedby-invalid  closedby='click' is not any, closerequest or none
page_before.html:11  error  dialog-no-name  dialog has no aria-labelledby or aria-label
page_before.html:11  error  tabindex-on-dialog  tabindex on dialog (MDN: never set it)
page_before.html:11  warn   multiple-autofocus  2 autofocus elements inside one dialog
page_before.html:11  warn   open-attribute  static open attribute opens non-modal; use showModal() or command=show-modal for a modal
page_before.html:15  warn   cancel-blocked-by-required  cancel button in a method=dialog form with required fields has no formnovalidate
page_before.html:19  error  dialog-no-name  dialog has no aria-labelledby or aria-label
page_before.html:19  warn   no-close-control  no method=dialog form or command=close button found inside; JS may add one
page_before.html:20  error  empty-button  button has no text or accessible name
page_before.html:20  error  labelledby-missing-target  aria-labelledby points to missing id 'nothere'
page_before.html: 11 errors, 7 warnings
```

**Steps 2 to 7, decisions.**

| Line | Change | Step |
|---|---|---|
| 4, 9 | Filters panel becomes a plain `popover` (anchored, click-away). Remove `aria-expanded` and `sticky` | 2, 4.1, 4.3 |
| 5 | Link invoker becomes a button | 4.2 |
| 6 | Dangling target: the user confirms the help panel was removed, so delete the button | 4.2 |
| 7, 10 | Edit-profile `div role="dialog"` becomes a native `dialog` with `aria-labelledby`, opened by `command="show-modal"` | 2, 3.1, 3.6, 5.1 |
| 8 | `command="open"` becomes `show-modal` on the delete dialog | 5.1 |
| 11 | Delete dialog: remove `open` and `tabindex`, set `closedby="closerequest"`, one `autofocus` on "Keep account", name via `aria-labelledby` | 3.1, 3.2, 3.5, 3.6 |
| 15 | Cancel button gets `formnovalidate` | 3.4 |
| 19, 20 | Newsletter dialog gets `aria-labelledby` and a close button. The unnamed dialog on line 20 is unused, so the user approves its removal | 3.1, 3.3 |
| Edit-profile | `cancel` handler asks before discarding typed data, because input 5 says closing loses data | 3.5 |

The user approves one batch. **Step 10.** The checker on the rebuilt file reports `0 errors, 0 warnings`. The user runs the keyboard, `:modal` and focus-return checks in the test environment and confirms them. Safari 26.2 is the oldest target for `command`, which the user confirmed. The report lists one open item: scroll lock on iOS is untested.

## Templates

### Finding dependants

```
grep -rnE "(showModal|show\(\)|showPopover|togglePopover|\.close\()" --include=*.js --include=*.ts --include=*.tsx .
grep -rnE "role=.(dialog|alertdialog).|aria-modal|getElementById\(.(modal|dialog)" --include=*.js --include=*.html --include=*.tsx .
```

### Report

```
DIALOG AND POPOVER REPORT: [pages], [date], branch [name]
Baseline: [n] errors, [n] warnings        After: [n] errors, [n] warnings
Overlays: [id: primitive chosen and why, one line each]
Changed: custom to native [n], names [n], focus [n], invokers [n], commands [n]
Data-loss guards added: [list or none]
Behaviour tests: keyboard [pass/fail], :modal [pass/fail], focus return [pass/fail],
                 reflow [pass/fail], scroll lock [pass/fail/not tested], reduced motion [pass/fail]
Server authorises destructive actions: [yes / unconfirmed]
Not tested: [browsers, iOS, screen readers, library internals]
Legal wording routed to: [person, or none]
```

### Starting prompt

```
Use the dialog-and-popover skill on [files]. Stack: [stack]. Target browsers:
[list]. Closing [overlay] loses data. Run the checker and choose a primitive for
each overlay first, and change nothing until I approve the plan.
```

Code for dialogs, popovers, animation and scroll lock sits in `references/patterns.md`.

## Failure modes and anti-patterns

| # | Failure | How to spot it | What to do instead |
|---|---|---|---|
| 1 | Div modal with a hand-built focus trap and `aria-hidden` on the page | `custom-dialog`. Tab reaches the page behind, or a screen reader can read it | Native `dialog` with `showModal()` (step 3.6). WebAIM found pages with ARIA averaged more errors than pages without |
| 2 | A modal opened with the `open` attribute or `show()` | `open-attribute`. Esc does nothing and the page behind stays clickable | `showModal()` or `command="show-modal"` |
| 3 | Wrong initial focus or lost return focus | Step 10.2 fails, or focus lands on a destructive button | The table in step 3.2. Test return focus. Check shadow DOM (step 3.8) |
| 4 | The wrong primitive: a popover for a decision, a dialog for a hint, `role="menu"` for navigation | Step 2 row does not match the purpose | Re-run step 2 with the purpose from input 4 |
| 5 | Page scrolls behind the modal, or a tall dialog cannot be reached | Step 10.5 and 10.6 fail, especially on iOS | Step 6 |

## Quality bar

Check every item before handing over. Rules are in the step named, not repeated here.

1. Gate 1 and gate 2 approvals are recorded and the work is on a named branch.
2. Baseline and final checker output are in the report, and the final run shows 0 errors on changed files.
3. Every overlay has a primitive chosen from the step 2 table, with the reason in the report.
4. Every dialog has a name, a visible close control and exactly one initial-focus decision (step 3).
5. Every popover invoker is a button whose target exists and carries `popover` (step 4).
6. No `aria-modal`, `role="dialog"` or `aria-hidden` workaround remains where a native element is used.
7. Step 10.2, 10.3 and 10.5 were run for each modal and the results are in the report.
8. Any overlay where closing loses data has a guard, or the user declined it in writing.
9. The report states whether the server authorises destructive actions.
10. No real personal data, credentials or live actions appear in tests or in the report.

## Cost

Figures are estimates from this skill's own count (words × 1.35 for prose, characters ÷ 3.5 for code and tables), not from a model tokeniser. Rival figures are estimates from the sizes shown on the pages I read.

| Item | Tokens | Status | Read condition |
|---|---|---|---|
| A. Description (always loaded) | 113 | Estimate | Every session with the skill installed |
| B. `SKILL.md` body | 5,768 | Estimate | When the skill fires |
| C. `references/patterns.md` | 1,085 | Estimate | Only when writing or rewriting markup, CSS or script |
| C. `references/appendix.md` | 981 | Estimate | Only to check a claim |
| Script output: `dialog_check.py` (this page's example) | 571 | Estimate | Every run. Capped at 60 lines by `head -60`, about 1,700 tokens |
| D. Typical run (A + B + patterns + script output) | 7,537 | Estimate | Common path |
| D. Worst case (A + B + both references + 60 lines of output) | 9,647 | Estimate | Large page, claim check |
| E. Cheaper path | 6,452 | Estimate | Run the checker, fix only flagged lines that need no new markup, skip the references |

Rival sizes, estimated from the pages I read: Community-Access modal-specialist about 10,000 characters, so roughly 2,900 tokens; thedaviddias popover about 2,300 characters of SKILL.md plus a `references/pattern.md` I could not size, so roughly 660 tokens before that file; workspace-brfis create-accessible-modals could not be sized from the page. This skill costs more to load than all three. What the script saves is the agent reading and reasoning over every overlay's markup, and I have not measured that saving.

## Handoffs

`html-forms.md` covers fields inside dialogs. `semantic-html.md` covers the page around them. `responsive-images.md` covers images inside lightboxes.

## Appendix

Tool-specific facts and the rival comparison sit in `references/appendix.md`. Read it only to check a claim.

## Sources

Last verified 24 October 2026 for every source, except where a date says otherwise.

- MDN, The Dialog element: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog
- MDN, Using the Popover API: https://developer.mozilla.org/en-US/docs/Web/API/Popover_API/Using
- W3C WAI-ARIA Authoring Practices Guide, Dialog (Modal) pattern (no update date shown): https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
- whatwg/html, Consider preventing page scroll when modal dialog is visible (issue 7732, open): https://github.com/whatwg/html/issues/7732
- OpenReplay, How to stop a page from scrolling while a dialog is open (30 January 2026): https://blog.openreplay.com/stop-page-scrolling-dialog-open/
- Mozilla Bugzilla 2050985, autofocus in dialog in shadow DOM (unconfirmed): https://bugzilla.mozilla.org/show_bug.cgi?id=2050985
- InfoQ, HTML invoker commands (28 January 2026): https://www.infoq.com/news/2026/01/html-invoker-commands
- WebAIM, The WebAIM Million 2026 (data February 2026): https://webaim.org/projects/million/
- Community-Access, accessibility-agents, modal-specialist (via skillselion.com). Rival implementation: https://skillselion.com/skills/community-access/accessibility-agents/modal-specialist
- workspace-brfis, create-accessible-modals (via tessl.io). Rival implementation: https://tessl.io/registry/workspace-brfis/create-accessible-modals/skills
- thedaviddias, ux-patterns-for-developers, popover (via skillselion.com). Rival implementation: https://skillselion.com/skills/thedaviddias/ux-patterns-for-developers/popover
- Anthropic, Agent Skills best practices: https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices

---
Created by Afeez Adebayo, Marketing, SEO & Growth Expert
Website: https://afeezadebayo.com
LinkedIn: https://www.linkedin.com/in/afeez-adebayo/
X: https://x.com/za_icon
Source: https://afeezadebayo.com/skills-md/frontend-development/html-semantics/dialog-and-popover/

Licence: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). You may copy, share and adapt this skill, including for commercial use, as long as you credit Afeez Adebayo and link to the source URL above. Keep this notice in any copy or adapted version.
