Native Interactive Elements 1: details/summary, dialog, popover and Invoker Commands
Modals, popovers and accordions are now in HTML: with Invoker Commands (command/commandfor, Baseline 2025-12), dialogs and popovers open and close without a line of JavaScript, and the browser manages focus and inert. In this post we measure Bookstore's "Delete book" confirmation, mini cart and FAQ accordion in Chromium, and cover the returnValue trap, requestClose(), :open, and closedby, popover="hint" and hidden="until-found", which Safari lacks, as progressive enhancement.
Version note: This post follows the HTML (HyperText Markup Language) Living Standard and Baseline as of November 2026; the examples were run with Chrome 158 (Playwright 1.65) and Nu Html Checker 26.11.9, and Safari and Firefox support comes from the web-features data.
Guess First
The book page in Bookstore's admin panel has a "Delete book" button, a confirmation window, a mini cart, an FAQ (Frequently Asked Questions) section and a collapsed table of contents. There is not a single line of JavaScript on the page:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Clean Architecture | Bookstore</title>
</head>
<body>
<header>
<button type="button" command="toggle-popover" commandfor="mini-cart">Show cart</button>
<div id="mini-cart" popover>
<h2>Your cart</h2>
<p>2 books, 84.90 USD</p>
<a href="/cart">Go to cart</a>
</div>
</header>
<main>
<h1>Clean Architecture</h1>
<button type="button" command="show-modal" commandfor="delete-dialog">Delete book</button>
<dialog id="delete-dialog" closedby="any" aria-labelledby="delete-title">
<form method="dialog">
<h2 id="delete-title">Delete this book?</h2>
<p>Clean Architecture will be removed from the catalog.</p>
<button value="cancel" autofocus>Cancel</button>
<button value="delete">Delete</button>
</form>
</dialog>
<section aria-labelledby="faq-title">
<h2 id="faq-title">Frequently asked questions</h2>
<details name="faq">
<summary>When will my order ship?</summary>
<p>Orders placed before 14:00 ship the same day.</p>
</details>
<details name="faq">
<summary>Can I return an e-book?</summary>
<p>E-books can be returned within 14 days if not downloaded.</p>
</details>
</section>
<section aria-labelledby="toc-title">
<h2 id="toc-title">Table of contents</h2>
<div id="toc-full" hidden="until-found">
<p id="chapter-22">Chapter 22: The Clean Architecture</p>
</div>
</section>
</main>
</body>
</html>
Does the confirmation window open when you press "Delete book"? If it does, can you click the page behind it, what does Esc do, and where does keyboard focus go when the window closes? And: if you open the first FAQ question and then click the second, does the first stay open?
Forms are done; in this post we move to the interactive elements the browser gives you without JavaScript. For years we wrote a modal, a dropdown and an accordion component in every project, or took them from a library. Today most of them are in HTML, and the browser handles focus, keyboard, stacking order and the accessibility tree for us. Visual styling with CSS (Cascading Style Sheets), animation and anchor positioning are in the upcoming CSS3: Flexbox, Grid ve Modern CSS series; the depth of focus management is in the Accessibility (a11y) series.
details/summary and the Exclusive Accordion
details is the oldest and simplest: summary is always visible and clickable, the rest of the content opens and closes. Enter and Space work on the keyboard, and screen readers announce the open/closed state. What is new is the name attribute: details elements sharing the same name form a group, and only one of them can be open at a time. That is exactly what Bookstore's FAQ needs; it has been Baseline since September 2024.
I wrote a Playwright script that walks the page in Chromium; it performs each step of the guessing question in order and prints the state:
const { chromium } = require('playwright');
const http = require('http');
const fs = require('fs');
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(fs.readFileSync('book.html'));
}).listen(18880);
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://localhost:18880/books/42');
const state = () => page.evaluate(() => {
const d = document.getElementById('delete-dialog');
const f = document.activeElement;
return `open=${d.open} modal=${d.matches(':modal')} returnValue="${d.returnValue}" ` +
`focus=${f.tagName.toLowerCase()} "${f.textContent.trim()}"`;
});
// Invoker Commands: a modal dialog without JavaScript
await page.getByRole('button', { name: 'Delete book' }).click();
console.log('after Delete book ->', await state());
await page.keyboard.press('Escape');
console.log('after Escape ->', await state());
await page.getByRole('button', { name: 'Delete book' }).click();
await page.getByRole('button', { name: 'Delete', exact: true }).click();
console.log('after Delete ->', await state());
// closedby="any": does clicking the backdrop close the dialog?
await page.getByRole('button', { name: 'Delete book' }).click();
console.log('reopened ->', await state());
await page.mouse.click(5, 5);
console.log('after backdrop ->', await state());
// popover: light dismiss
await page.getByRole('button', { name: 'Show cart' }).click();
console.log('cart open:', await page.locator('#mini-cart').evaluate(p => p.matches(':popover-open')));
await page.mouse.click(5, 400);
console.log('cart open after outside click:',
await page.locator('#mini-cart').evaluate(p => p.matches(':popover-open')));
// details name: exclusive accordion
await page.locator('summary').nth(0).click();
await page.locator('summary').nth(1).click();
console.log('details open:', await page.locator('details').evaluateAll(ds => ds.map(d => d.open)));
// hidden="until-found": does it open when reached through a fragment?
await page.goto('http://localhost:18880/books/42#chapter-22');
console.log('toc hidden attr:', await page.locator('#toc-full').evaluate(e => e.getAttribute('hidden')),
'| visible:', await page.locator('#chapter-22').isVisible());
await browser.close();
server.close();
})();
after Delete book -> open=true modal=true returnValue="" focus=button "Cancel"
after Escape -> open=false modal=false returnValue="" focus=button "Delete book"
after Delete -> open=false modal=false returnValue="delete" focus=button "Delete book"
reopened -> open=true modal=true returnValue="delete" focus=button "Cancel"
after backdrop -> open=false modal=false returnValue="delete" focus=button "Delete book"
cart open: true
cart open after outside click: false
details open: [ false, true ]
toc hidden attr: null | visible: true
The second-to-last line shows the accordion: first the first question, then the second was clicked, and in the end only the second is open. That is the last answer to the guessing question, with no JavaScript and two attributes.
The last line shows a newer feature: hidden="until-found". A plain hidden hides content completely and the browser's find-in-page (Ctrl+F) cannot find it. until-found hides content but keeps it searchable: when the user searches or a link points to an id inside it, the browser removes the attribute and shows the content. The script came to the page with #chapter-22 and the hidden attribute was removed. Ideal for Bookstore's long table of contents: collapsed by default, but anyone who searches for "Chapter 22" can find it. Support is mixed, though: Chrome has it since 102, Firefox since 148, Safari does not. In Safari the content stays closed like a plain hidden; so use this as a progressive enhancement, only where the hidden content is also reachable another way (for example a "Show all" button).
dialog: show(), showModal(), the Top Layer and inert
dialog opens in two modes. show() opens a non-modal window: the rest of the page stays usable, like a chat box or a notification panel. showModal() opens it as a modal and brings three things with it. First, the top layer: the dialog is drawn in a separate layer above every z-index on the page; no overflow: hidden or stacking context can clip it. Second, inert: the rest of the page becomes unclickable and unfocusable, and hidden from screen readers. Third, the ::backdrop pseudo-element and closing with Esc.
The first two lines answer the guessing question. Pressing "Delete book" opened the dialog as a modal (modal=true) and focus went straight to the "Cancel" button I gave autofocus. Esc closed the dialog and focus returned to the "Delete book" button that opened it. That is all the behaviour a keyboard user needs to not get lost on the page, and I did not write a line of code. The proof of inert came while writing the script: when I tried to click a button behind the open dialog, Playwright could not click it and kept waiting with "dialog intercepts pointer events".
A suggestion for focus: in a window confirming a destructive operation, give autofocus to "Cancel", not "Delete". A user who presses Enter by reflex should not delete the book.
form method="dialog", returnValue and requestClose()
The form inside the dialog has method="dialog". When such a form is submitted, no request goes to the server; the dialog closes and the value of the submitting button is written to the dialog's returnValue. The third line shows it: pressing "Delete" closed the dialog and returnValue became "delete". In JavaScript you check this value in the close event and decide what to do.
But watch the fourth and fifth lines: when the dialog opens again, returnValue is still "delete", and it stays that way when the dialog is closed by clicking the backdrop. showModal() does not reset the value, and closing with Esc or the backdrop does not write a new one. Code that only checks returnValue === 'delete' in the close event deletes the book again when the user changes their mind on the second opening and clicks outside. That is why, for destructive operations, I prefer a real form submission, shown below, over method="dialog".
There is also a requestClose() method for closing a dialog, Baseline since May 2025. Unlike close(), it fires the cancel event first: if the form inside the dialog is half filled, you can block closing and ask the user. The second script measures this and two newer features from the rest of the post:
const { chromium } = require('playwright');
const fs = require('fs');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(fs.readFileSync('book.html', 'utf8'));
page.on('console', m => console.log(m.text()));
await page.evaluate(() => {
// ToggleEvent.source: which button opened the popover?
document.getElementById('mini-cart').addEventListener('toggle', e =>
console.log(`toggle ${e.oldState} -> ${e.newState}, source: ${e.source?.textContent}`));
// requestClose() fires a cancel event; you can block closing if the form has data
document.getElementById('delete-dialog').addEventListener('cancel', e => {
console.log('cancel event, preventing close');
e.preventDefault();
});
});
await page.getByRole('button', { name: 'Show cart' }).click();
await page.getByRole('button', { name: 'Show cart' }).click();
await page.locator('summary').first().click();
await page.getByRole('button', { name: 'Delete book' }).click();
console.log(':open ->', await page.evaluate(() =>
[...document.querySelectorAll(':open')].map(e => e.id || e.tagName.toLowerCase()).join(', ')));
await page.evaluate(() => document.getElementById('delete-dialog').requestClose());
console.log('dialog still open:', await page.evaluate(() => document.getElementById('delete-dialog').open));
await browser.close();
})();
toggle closed -> open, source: Show cart
toggle open -> closed, source: Show cart
:open -> delete-dialog, details
cancel event, preventing close
dialog still open: true
The last two lines: requestClose() fired the cancel event, the handler called preventDefault(), and the dialog stayed open. I will come back to the first two lines in the popover and Invoker Commands section, and to the :open line in the middle at the end of that section.
Light Dismiss with closedby, and Progressive Enhancement
By default a modal dialog closes only with Esc or a button; clicking outside does nothing. The closedby attribute controls this: any accepts clicking outside (light dismiss), Esc and buttons; closerequest only Esc and buttons; none only buttons. Our dialog has closedby="any", and in the fifth line of the interact.js output, clicking the backdrop closed it.
But closedby is not Baseline: Chrome has it since 134, Firefox since 141, and released versions of Safari do not. A note: Playwright's WebKit build follows WebKit's development branch, where the closedBy property exists; so support may be on its way, but it is not the browser a Safari user has today. Progressive enhancement is natural here: a browser without support ignores the attribute and the dialog still closes with Esc and buttons. If a user loses nothing when a feature does not work, it is safe to use it today. closedby is exactly that kind of feature.
The popover Attribute: auto, manual and hint
A popover is a lighter concept than a dialog: you give any element the popover attribute, and that element starts hidden and moves to the top layer when opened. It is for panels that do not lock the page, such as a mini cart, a user menu or a "?" hint. Baseline since January 2025.
There are three kinds. popover or popover="auto" supports light dismiss: clicking outside or Esc closes it, and it also closes any other open auto popover, so two menus never stay open on top of each other. In the interact.js output, the cart opened and closed when an empty part of the page was clicked. popover="manual" closes only when closed explicitly; suitable for a toast such as "Book added to cart". popover="hint" is designed for hints that open on hover and does not close auto popovers when it opens. Hint arrived with Chrome 151 and Firefox 153 and is not in released versions of Safari; a browser without support treats it like an invalid value and falls back to manual. The interestfor attribute that opens a hint on hover is only in Chrome 149+; both have limited availability as of November 2026.
A popover is not modal: the rest of the page does not become inert and focus does not move into it automatically. Use a dialog for anything that requires the user to make a decision, and a popover for anything that shows information and is easy to dismiss.
Invoker Commands: Open and Close Without JavaScript
So far everything was HTML, but one question is still open: who calls showModal() to open the dialog? The answer is two attributes on the page: commandfor gives the id of the target element, command the action. The "Delete book" button carries command="show-modal" commandfor="delete-dialog"; the "Show cart" button command="toggle-popover" commandfor="mini-cart". The other commands in the spec are close, request-close, show-popover and hide-popover. Values starting with -- are custom commands: the browser fires a command event on the target and your JavaScript does the rest. Invoker Commands became Baseline newly available in December 2025 with the arrival of Safari 26.2.
For popovers there is an older way as well: the popovertarget attribute. It works too and has been Baseline since January 2025; in new code I prefer command, because it is a single model for both dialogs and popovers.
The first two lines of extras.js are the popover's toggle event: along with oldState and newState, it now carries source, so you know which button opened the popover. That helps when several buttons open the same popover (for example a "Show cart" at the top and at the bottom of the page). The line in the middle is the :open pseudo-class: it selects the currently open dialog and details with one selector. ToggleEvent.source and :open became Baseline in May 2026; thanks to :open, you no longer need the details[open] attribute selector to style an open accordion.
Above I said I prefer a real form submission for destructive operations because of the returnValue trap. With Invoker Commands that is JavaScript-free too:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Admin | Bookstore</title>
</head>
<body>
<main>
<h1>Clean Architecture</h1>
<button type="button" command="show-modal" commandfor="delete-dialog">Delete book</button>
<dialog id="delete-dialog" aria-labelledby="delete-title">
<h2 id="delete-title">Delete this book?</h2>
<p>Clean Architecture will be removed from the catalog.</p>
<form method="post" action="/admin/books/42/delete">
<button type="button" command="close" commandfor="delete-dialog" autofocus>Cancel</button>
<button type="submit">Delete</button>
</form>
</dialog>
</main>
</body>
</html>
server: GET /admin/books/42
after Cancel, dialog open: false
server: POST /admin/books/42/delete
"Cancel" is a type="button" and closes the dialog with command="close"; nothing went to the server. "Delete" really POSTs the form, and the decision to delete is made on the server. There is no JavaScript looking at returnValue, so there is no trap either.
In Production
Baseline dates matter in this post. Invoker Commands is newly available, which means on an iPhone with a Safari older than 26.2 the "Delete book" button does nothing. If those versions still have a meaningful share in Bookstore's analytics, there are two options: load the widely used invokers polyfill, or feature-detect with 'command' in HTMLButtonElement.prototype and attach a few lines of click handler when it is missing. It moves to widely available in June 2028; until then, choose your target browser list deliberately. The relationship between Angular's browser target and Baseline is in the last post of the series.
A testing caveat: I made the measurements in this post in Chromium. Because Playwright's WebKit build follows WebKit's development branch, it appears to support features such as closedby, popover="hint" and hidden="until-found" that are not in Safari's released version. When deciding about Safari support, look at the web-features data and a real Safari, not Playwright's WebKit.
When wrapping these elements in frameworks, mind their place in the DOM (Document Object Model). The top layer lifts the dialog visually above everything, but its place in the DOM does not change. If in Angular you render the dialog conditionally with @if and remove it from the DOM while it is open, behaviours such as the close event and focus returning to the opening button may not work as you expect. Keeping the dialog in the DOM and managing it with open/close is more predictable.
Common Pitfalls
First, faking a modal with a div and z-index. No top layer, so a parent's overflow clips it; no inert, so the page behind can be reached with Tab; no Esc, no focus management. Writing all of that by hand means reinventing what dialog gives you for free.
Second, assuming returnValue is reset on every opening. It is not; do the destructive operation with a form that goes to the server, or clear the value yourself before opening.
Third, giving autofocus to the destructive button. Let focus start on "Cancel".
Fourth, putting a link or button inside summary. summary is already an interactive element; a second interactive element inside it confuses both clicks and screen readers.
Fifth, assuming closedby, popover="hint" or hidden="until-found" work everywhere. None of the three is in Safari as of November 2026; use them only where the user loses nothing in their absence.
A Small Experiment
★☆☆ Open book.html from this post in Chrome and, without touching the mouse, open the dialog with the keyboard only, move inside it with Tab and close it with Esc. Watch where focus goes. Then also open the dialog non-modally from the DevTools Console with document.getElementById('delete-dialog').show(), and see the difference by clicking the page behind it and moving through it with Tab.
★★☆ Add a third question to the FAQ and make the heading of the open question bold with the details:open summary selector. Then add a "Book added to cart" toast with popover="manual" and test whether the auto popover opened with "Show cart" closes it.
★★★ Rewrite a modal component from your own project with dialog and Invoker Commands. Compare the old and new versions with the keyboard: can the page behind be reached with Tab, does Esc work, where does focus return when it closes? Note the number of lines deleted.
In the next post we move to the second half of the native interactive elements: select, datalist, customizable select whose design can finally be changed with CSS, and output, progress and meter.