CSS Module 17 – Forms, tables, popovers and anchor positioning
Popovers, dialogs and disclosure widgets
Style dialogs, popovers and details with ::backdrop, :modal, :popover-open and :open, and animate them in and out with @starting-style.
What you will learn
- Choose between a modal dialog, a popover and a details element for a piece of interface
- Use ::backdrop, :modal, :popover-open and :open to style open states and the top layer
- Build entry and exit transitions with @starting-style and transition-behavior allow-discrete
- Build an accordion from details and summary with ::details-content and the name attribute
- Explain which behaviour HTML provides (focus, Escape, light dismiss) and what CSS only styles
Before you start
What you’ll learn · Before you start
What you will learn
- Choose between a modal dialog, a popover and a details element for a piece of interface
- Use ::backdrop, :modal, :popover-open and :open to style open states and the top layer
- Build entry and exit transitions with @starting-style and transition-behavior allow-discrete
- Build an accordion from details and summary with ::details-content and the name attribute
- Explain which behaviour HTML provides (focus, Escape, light dismiss) and what CSS only styles
Before you start
On this page
HTML has three built-in ways to show content that opens and closes. A <dialog> opened with showModal() is
modal: it sits above the whole page, the rest of the page is inert, and Escape closes it. The popover
attribute makes any element a non-modal overlay that closes when the person clicks elsewhere or presses Escape, for
menus, tooltips and help panels. <details> with its <summary> opens and closes content in the page itself,
for disclosures and accordions. HTML provides the behaviour: focus, keyboard handling, light dismiss. CSS styles the
result. Open dialogs and popovers live in the top layer, above every z-index; ::backdrop styles the layer
behind a modal dialog; :modal, :popover-open and :open select the open states; and @starting-style with
transition-behavior: allow-discrete animates them in and out. The results below come from Chromium 153, Firefox 155
and WebKit 26.6, which differ most at closing time.
Which element for which job
- Modal dialog, opened with
showModal()or a button withcommand="show-modal": it sits in the top layer and the rest of the page is inert. Escape closes it; a click outside closes it only withclosedby="any". It matches:modaland:open. - Non-modal dialog, opened with
show(): it stays in the page, which stays usable, and neither Escape nor a click outside closes it. It matches:open. - Popover
auto, opened withshowPopover()or a button withpopovertarget: it sits in the top layer, the page stays usable, and Escape or a click outside closes it. It matches:popover-open. - Popover
manual: the same, except that only your script closes it. details: a click on itssummaryopens it in the page, not in the top layer. Escape and outside clicks leave it open; another click on the summary closes it, and so does opening anotherdetailswith the samename. It matches:open.
Use a modal dialog when the page must wait for an answer, such as “Confirm the payment?”. Use a popover for
something the person can ignore or dismiss: a menu, a date picker, a help panel. popover="hint" is for tooltips,
and popover="manual" for things that stay until your code hides them, such as a chat panel. Use details when the
content belongs in the page and only needs to fold away. A modal dialog closes on Escape because the HTML standard
gives it the “close request” state by default; a dialog opened with show() gets the “none” state and stays open.
The top layer and ::backdrop
A banner with position: fixed and z-index: 2147483647, the largest 32-bit integer, covers the page. The dialog
and the popover sit inside a card with z-index: 1, a stacking context of its own:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A dialog against a banner with the highest z-index</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.banner { position: fixed; inset: 0; z-index: 2147483647; background: rgb(255 200 0 / 0.5); } /* covers the page */
.card { position: relative; z-index: 1; } /* a stacking context around the dialog and the popover */
dialog, [popover] { inline-size: 200px; block-size: 100px; padding: 0; }
</style>
</head>
<body>
<div class="banner" id="banner"></div>
<div class="card">
<dialog id="dialog">Confirm the payment?</dialog>
<div popover id="help">Help text</div>
</div>
<details id="details"><summary>Refund rules</summary>Five to seven working days.</details>
<script>
const dialog = document.getElementById('dialog');
const help = document.getElementById('help');
const onTop = () => document.elementFromPoint(innerWidth / 2, innerHeight / 2).id;
const states = (el) => [':modal', ':popover-open', ':open'].filter((s) => el.matches(s)).join(' ') || 'none';
dialog.showModal();
console.log(`showModal(): #${onTop()} on top; matches ${states(dialog)}; ::backdrop ${getComputedStyle(dialog, '::backdrop').backgroundColor}`);
dialog.close();
dialog.show();
console.log(`show(): #${onTop()} on top; matches ${states(dialog)}`);
dialog.close();
help.showPopover();
console.log(`showPopover(): #${onTop()} on top; matches ${states(help)}; ::backdrop ${getComputedStyle(help, '::backdrop').backgroundColor}`);
help.hidePopover();
document.getElementById('details').open = true;
console.log(`details opened: matches ${states(document.getElementById('details'))}`);
</script>
</body>
</html> showModal(): the dialog was on top, and it matched:modaland:open.show(): the banner covered the dialog, which matched:open.showPopover(): the popover was on top, and it matched:popover-open.details.open = true: not in the top layer; it matched:open.
All three engines agreed. showModal() and showPopover() move the element out of its stacking contexts into the
top layer, which is painted after the whole page, in the order elements were added to it, so no z-index can cover
it. No ancestor’s overflow: hidden clips it either: inside a 40px box with overflow: hidden, the modal dialog and
the popover still showed in full in all three engines, while a dialog opened with show() was clipped to the box.
Each top-layer element gets a ::backdrop just below it. The browser’s default style dims the page behind a modal
dialog with 10 % black, rgba(0, 0, 0, 0.1), and leaves a popover’s backdrop transparent, as all three engines
reported. ::backdrop inherits from its dialog, so custom properties set on the dialog work inside it, as all three
engines showed too.
Where an open dialog or popover is painted
Text description of the diagram
The diagram has three layers, from the top down, each painted above the next.
- The top layer holds modal dialogs and open popovers, in the order they opened, each above its own ::backdrop.
- Below it are the page's stacking contexts. Even a banner with z-index 2147483647 stays here, under the top layer.
- At the bottom is a card with z-index 1, where the dialog sits in the HTML. showModal() and showPopover() lift the box into the top layer; a dialog opened with show() stays inside this card.
A dimmed page should still let the dialog stand out. The program works out, with the WCAG formula, how dark a black backdrop must be for a white dialog without a border to reach 3:1 against the white page behind it:
// How dark must a dialog's ::backdrop be before a white dialog stands out from the white page behind it? The
// backdrop is black at an alpha, composited over the page in sRGB; the ratio is WCAG 2.2's contrast formula. The
// browser's default ::backdrop for a modal dialog is rgba(0, 0, 0, 0.1).
const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const luminance = (grey) => lin(grey / 255); // a grey has three equal channels
const ratio = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
const page = 255; // a white page
const dialog = luminance(255); // a white dialog without a border
for (const alpha of [0.1, 0.25, 0.4, 0.42, 0.5, 0.6]) {
const dimmed = page * (1 - alpha); // black at `alpha` over the page
const r = ratio(dialog, luminance(dimmed));
console.log(`backdrop alpha ${alpha.toFixed(2)}: page dimmed to ${dimmed.toFixed(1)}/255, dialog edge ${r.toFixed(2)}:1${r >= 3 ? ' (3:1 or more)' : ''}`);
} Output
backdrop alpha 0.10: page dimmed to 229.5/255, dialog edge 1.25:1 backdrop alpha 0.25: page dimmed to 191.3/255, dialog edge 1.83:1 backdrop alpha 0.40: page dimmed to 153.0/255, dialog edge 2.85:1 backdrop alpha 0.42: page dimmed to 147.9/255, dialog edge 3.04:1 (3:1 or more) backdrop alpha 0.50: page dimmed to 127.5/255, dialog edge 3.98:1 (3:1 or more) backdrop alpha 0.60: page dimmed to 102.0/255, dialog edge 5.74:1 (3:1 or more)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node backdrop_contrast.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The default 10 % leaves only 1.25:1, so give the dialog a border or a shadow, or darken the backdrop to about 42 %
or more. backdrop-filter: blur(4px) on ::backdrop softens the page too, and is Baseline newly available.
Open states: :modal, :popover-open and :open
:modal matches only a dialog opened with showModal(), the right hook for styles that a modal dialog needs and a
non-modal one doesn’t. :popover-open matches an open popover. :open is newer and broader: it matches an open
dialog, an open details and a select whose picker is open, but not a popover, as the top-layer test showed.
:open is Baseline newly available, and dialogs, popovers and :open are an Interop 2026 focus area, so engines are
still aligning details. Two habits keep older engines working. First, a selector an engine doesn’t know makes it drop
the whole rule, every selector of the list included, so write dialog[open] and details[open] in rules that must
always apply; the open attribute is set whenever these elements are open. Second, test that open content is
reachable with the keyboard and that focus lands inside a modal dialog: HTML moves it there when the dialog opens,
and back to the button that opened it when it closes.
Animating in and out
An element that appears has no “before” style to transition from, so a fade-in does nothing on its own.
@starting-style supplies that before style. Closing is harder: a closed dialog or popover gets display: none, which
removes it at once. transition-behavior: allow-discrete (here in the shorthand) lets display wait until the end
of the transition, and overlay, also with allow-discrete, keeps the element in the top layer for the same time:
/* A confirmation dialog and a help popover that fade and slide in, and fade out where the engine allows it. */
dialog,
[popover] {
opacity: 0;
translate: 0 16px;
transition:
opacity 300ms,
translate 300ms,
display 300ms allow-discrete, /* stay displayed until the fade-out ends */
overlay 300ms allow-discrete; /* stay in the top layer until then (Chromium only) */
}
dialog[open], /* [open], not :open: an older engine would drop the whole rule */
:popover-open {
opacity: 1;
translate: 0 0;
}
@starting-style { /* the values to animate from when the element appears */
dialog[open],
:popover-open {
opacity: 0;
translate: 0 16px;
}
}
dialog::backdrop {
background-color: rgb(0 0 0 / 0);
transition:
background-color 300ms,
display 300ms allow-discrete,
overlay 300ms allow-discrete;
}
dialog[open]::backdrop {
background-color: rgb(0 0 0 / 0.5);
}
@starting-style {
dialog[open]::backdrop {
background-color: rgb(0 0 0 / 0);
}
}
dialog :focus-visible {
outline: 3px solid #1f4e8c;
outline-offset: 2px;
}
/* Motion the person did not start can make some people dizzy: keep the fade, drop the slide. Repeat the whole
list: changing only transition-property would pair allow-discrete with the wrong properties. */
@media (prefers-reduced-motion: reduce) {
dialog,
[popover] {
transition:
opacity 300ms,
display 300ms allow-discrete,
overlay 300ms allow-discrete;
}
} <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Entry and exit transitions of a dialog and a popover</title>
<link rel="stylesheet" href="dialog-motion.css">
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
</style>
</head>
<body>
<button type="button" commandfor="confirm" command="show-modal">Pay ₹1,250</button>
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Confirm the payment?</h2>
<button type="button" commandfor="confirm" command="close">Cancel</button>
</dialog>
<div popover id="help">The amount includes delivery.</div>
<script>
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const running = (el) => el.getAnimations({ subtree: true }).map((a) => a.transitionProperty).sort().join(', ') || 'none';
async function check(el, open, close) {
open();
await wait(100);
console.log(`#${el.id} 100ms after opening: transitions ${running(el)}`);
await wait(400);
close();
await wait(100);
console.log(`#${el.id} 100ms after closing: display ${getComputedStyle(el).display}, transitions ${running(el)}`);
await wait(400);
}
addEventListener('load', async () => {
const dialog = document.getElementById('confirm');
const help = document.getElementById('help');
await check(dialog, () => dialog.showModal(), () => dialog.close());
await check(help, () => help.showPopover(), () => help.hidePopover());
});
</script>
</body>
</html> - 100ms after
showModal(): the opacity, translate and backdrop transitions were running in all three engines. - 100ms after
close()orhidePopover(): Chromium 153 still showed the element, with its display and overlay transitions running; Firefox 155 and WebKit 26.6 had already setdisplay: none, with nothing running.
The entry animation runs everywhere. The exit animation runs only in Chromium: Firefox doesn’t transition display
at all, and WebKit, which does transition display on ordinary elements, removed the closing dialog and popover at
once. That is a safe fallback, because in those engines the element simply disappears. overlay matters in Chromium:
a test without it, screenshotted 150ms into the exit, showed the dialog still fading, at opacity 0.78, but out of the
top layer and so hidden behind a covering element of the page; with overlay it faded out on top.
Respect prefers-reduced-motion: reduce, as the stylesheet does by keeping the fade and dropping the slide. WCAG
2.2’s success criterion 2.3.3 (level AAA) asks that motion started by an interaction can be switched off unless it is
essential. Write the whole transition list in that block. Changing only transition-property there is a trap: the
comma lists pair up by position again, allow-discrete lands on the wrong property, and in a reduced-motion run in
Chromium the dialog then vanished on close instead of fading.
Details and summary: disclosures and accordions
A details element is open when it has the open attribute. Its summary is the button that toggles it, with a
disclosure triangle drawn as its ::marker, so list-style: none on the summary or a ::marker rule restyles it.
::details-content, Baseline newly available, is the part that opens and closes, so you can give it padding or
animate it. Several details with the same name form an exclusive accordion, where opening one closes the others:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>An accordion from details and summary</title>
<style>
body { margin: 16px; font: 16px/24px Arial, sans-serif; }
:root { interpolate-size: allow-keywords; } /* lets block-size animate to and from auto (Chromium only) */
details { border-block-end: 1px solid #c5ccd6; }
summary { padding: 8px 0; cursor: pointer; }
summary:focus-visible { outline: 3px solid #1f4e8c; outline-offset: 2px; }
details::details-content { /* the part that opens and closes */
block-size: 0;
overflow: clip;
transition: block-size 400ms, content-visibility 400ms allow-discrete;
}
details[open]::details-content { block-size: auto; } /* [open], not :open: older engines know ::details-content but not :open */
details p { margin: 0 0 8px; block-size: 96px; }
@media (prefers-reduced-motion: reduce) {
details::details-content { transition: none; }
}
</style>
</head>
<body>
<details name="faq" id="refunds">
<summary>How long does a refund take?</summary>
<p>Refunds go back to the card or account you paid from in five to seven working days.</p>
</details>
<details name="faq" id="delivery">
<summary>Can I change the delivery address?</summary>
<p>Yes, until the order is packed. Open the order and choose a new address.</p>
</details>
<script>
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const refunds = document.getElementById('refunds');
const delivery = document.getElementById('delivery');
addEventListener('load', async () => {
refunds.open = true;
await wait(100);
console.log(`100ms after opening: ${refunds.getBoundingClientRect().height.toFixed(1)}px tall, :open ${refunds.matches(':open')}`);
await wait(500);
console.log(`600ms after opening: ${refunds.getBoundingClientRect().height.toFixed(1)}px tall`);
delivery.open = true; // same name: opening one closes the other
await wait(50);
console.log(`after opening the second: refunds open ${refunds.open}, delivery open ${delivery.open}`);
});
</script>
</body>
</html> - 100ms after opening: 80.2px tall and still growing in Chromium 153; already fully open in Firefox 155 (145.5px) and WebKit 26.6 (145.0px).
- 600ms after opening: 145.0px in Chromium too.
- Opening the second: it closed the first in all three engines.
Animating to the content’s natural height needs interpolate-size: allow-keywords, which lets block-size move to
and from auto, and only Chromium supports it, so Firefox and WebKit opened the panel at once, which is fine. Closed
content gets content-visibility: hidden from the browser, which is why the transition lists content-visibility
with allow-discrete as well. The rule that opens the content uses details[open]: Safari 18.4 to 26.4 and Chrome
131 and 132 know ::details-content but not :open, and would drop a :open rule and keep the content at 0px.
What HTML handles and CSS can’t
CSS only styles these widgets. The behaviour comes from HTML, so use the real elements instead of styled divs:
- A modal dialog moves focus inside when it opens and back to the opener when it closes, keeps Tab inside, makes the
page inert and closes on Escape.
closedby="any"adds closing on a backdrop click: Chromium and Firefox list it, and the WebKit build we ran already supports it, though web-features lists no released Safari yet. - Popovers get light dismiss and Escape from the browser, and a
popovertargetbutton is wired to its popover for assistive technology. In all three engines, showing anautopopover closed the otherautoandhintpopovers, showing ahintclosed only other hints, Escape closed the most recent one, andmanualpopovers stayed open through everything. command="show-modal"andcommandfor="dialog-id"on a button open a modal dialog with no script, Baseline newly available. In all three engines a click opened the dialog and moved focus to its Close button.
Practise: which popovers stay open
Write the rules that decide which popovers are open after a sequence of steps:
Exercise · Medium · JavaScript
Predict which popovers stay open
A page has menus (popover="auto"), tooltips (popover="hint") and a chat panel (popover="manual"). Before you style them, know which ones are open. Write openPopovers(types, steps) in popovers.mjs and export it.
types maps each popover's id to 'auto', 'hint' or 'manual'. steps is a list of strings: 'show <id>', 'hide <id>', 'escape' (the person presses Escape) and 'outside' (the person clicks outside every popover). Return the ids that are open after the last step, in the order they were opened, oldest first. These are the rules all three engines followed in the lesson's run, for popovers that are not nested inside each other:
- Showing an
autopopover closes every other openautoandhintpopover. - Showing a
hintpopover closes the other openhintpopovers, but not theautoones. - Showing a
manualpopover closes nothing, and nothing closes amanualpopover except'hide <id>'. 'escape'closes the most recently openedautoorhintpopover that is still open.'outside'closes every openautoandhintpopover.- Showing a popover that is already open changes nothing.
The sample tests import the function from popovers.mjs and run in your browser.
Starter code · popovers.mjs
/** The ids of the open popovers after the steps, oldest first. */
export function openPopovers(types, steps) {
// Replace this line with your code.
return [];
} The sample tests · popovers.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { openPopovers } from './popovers.mjs';
const types = { menu: 'auto', account: 'auto', tip: 'hint', price: 'hint', chat: 'manual' };
test('an auto popover closes the other auto and hint popovers', () => {
assert.deepEqual(openPopovers(types, ['show menu', 'show account']), ['account']);
assert.deepEqual(openPopovers(types, ['show account', 'show tip', 'show menu']), ['menu']);
});
test('a hint closes other hints, but leaves the auto popover open', () => {
assert.deepEqual(openPopovers(types, ['show menu', 'show tip']), ['menu', 'tip']);
assert.deepEqual(openPopovers(types, ['show menu', 'show tip', 'show price']), ['menu', 'price']);
});
test('a manual popover stays open whatever happens, until it is hidden', () => {
assert.deepEqual(openPopovers(types, ['show chat', 'show menu', 'show tip', 'outside', 'escape']), ['chat']);
assert.deepEqual(openPopovers(types, ['show chat', 'show menu', 'hide chat']), ['menu']);
});
test('escape closes the most recent auto or hint popover, one at a time', () => {
assert.deepEqual(openPopovers(types, ['show menu', 'show tip', 'escape']), ['menu']);
assert.deepEqual(openPopovers(types, ['show menu', 'show chat', 'show tip', 'escape', 'escape']), ['chat']);
assert.deepEqual(openPopovers(types, ['show chat', 'escape']), ['chat']);
});
test('a click outside closes every auto and hint popover', () => {
assert.deepEqual(openPopovers(types, ['show menu', 'show tip', 'outside']), []);
});
test('showing an open popover changes nothing', () => {
assert.deepEqual(openPopovers(types, ['show menu', 'show tip', 'show menu']), ['menu', 'tip']);
assert.deepEqual(openPopovers(types, ['show chat', 'show chat']), ['chat']);
}); A hint
Keep one array of the open ids, oldest first. Each step filters it or appends to it: for 'show', filter out the types that the new popover closes, then push its id; for 'escape', find the last id whose type is not 'manual' and remove it.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is the top layer, and why can’t z-index place an element above a modal dialog?
The top layer is a layer that browsers paint after the whole document, above every stacking context. A dialog opened
with showModal(), an open popover and a fullscreen element are moved into it, in the order they open, each with a
::backdrop just below it. z-index only orders boxes within a stacking context, and every stacking context of the
page is painted before the top layer, so even z-index: 2147483647 on a fixed banner stays underneath; in a measured
test a modal dialog inside a z-index: 1 card covered such a banner in Chromium, Firefox and WebKit. An ancestor’s
overflow: hidden doesn’t clip a top-layer element either. A dialog opened with show() is not in the top layer, so
it is stacked like any other box.
Key takeaways
- Modal dialog for decisions, popover for dismissible overlays,
detailsfor content that folds away. showModal()andshowPopover()put the element in the top layer, above everyz-index;show()doesn’t.:modal,:popover-openand:openselect open states; usedialog[open]anddetails[open]where an older engine must not drop the rule.@starting-styleanimates the entry;displayandoverlaywithallow-discretekeep the exit visible in Chromium, while Firefox and WebKit remove the element at once.- The default backdrop is 10 % black, 1.25:1 against a white dialog; add a border or darken it.
- HTML provides focus handling, Escape and light dismiss; CSS only styles them.
References
- HTML Standard: the popover attribute (WHATWG)
- HTML Standard: popover light dismiss (WHATWG)
- HTML Standard: the dialog element (WHATWG)
- HTML Standard: the closedby attribute (WHATWG)
- HTML Standard: the details element (WHATWG)
- HTML Standard: the default rendering of dialogs and popovers (WHATWG)
- CSS Positioned Layout Module Level 4: the top layer, ::backdrop and overlay (W3C CSS Working Group)
- CSS Pseudo-Elements Module Level 4: ::details-content (W3C CSS Working Group)
- Selectors Level 4: :open, :popover-open and :modal (W3C CSS Working Group)
- CSS Transitions Level 2: @starting-style and transition-behavior (W3C CSS Working Group)
- CSS Values and Units Module Level 5: interpolate-size (W3C CSS Working Group)
- Media Queries Level 5: prefers-reduced-motion (W3C CSS Working Group)
- WAI-ARIA Authoring Practices Guide: Dialog (Modal) Pattern (W3C)
- Understanding WCAG 2.2: Animation from Interactions (W3C)
- Using the Popover API (MDN Web Docs) (Mozilla)
- transition-behavior (MDN Web Docs) (Mozilla)
- Web features explorer: popover (W3C WebDX Community Group)
- Web features explorer: :open (W3C WebDX Community Group)
- Web features explorer: ::details-content (W3C WebDX Community Group)
- Interop 2026 focus areas (web-platform-tests Interop team)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress