Native widgets
details, dialog, progress, meter, output, popover — components the browser ships, so you don't have to build them.
A common pattern in front-end work: a designer asks for a collapsible FAQ. You reach for a UI library, install three megabytes of dependencies, wire up a state hook, juggle ARIA attributes, and ship a <button>-and-<div> combo that almost matches the spec.
Meanwhile, the browser has shipped <details> and <summary> for years. Two tags. No JavaScript. Keyboard, screen-reader, and animation support — all there.
This lesson tours the native widgets browsers already give you. You will reach for libraries less often, and your pages will be smaller and more accessible when you do.
details and summary
A <details> element is a disclosure widget — a chunk of content that can be hidden or revealed. The <summary> inside is the visible header that toggles it.
<details> <summary>What is sourdough?</summary> <p>A bread leavened by wild yeast living in a flour-and-water starter, rather than added commercial yeast.</p> </details> <details open> <summary>How long does the dough rise?</summary> <p>Anywhere from 4 to 24 hours, depending on the temperature.</p> </details>
The first details is closed by default. The second has the open attribute, so it starts open. Click the summary or press Enter on it (when keyboard-focused) to toggle.
The browser handles every accessibility detail: the summary becomes the focusable trigger, the contents announce as a region, the open/closed state is exposed to screen readers as aria-expanded. You wire up zero JavaScript.
A useful picture: <details> is a folder in a desktop file manager. The summary is the folder name; clicking it expands the contents in place.
Style <summary> however you want — change the disclosure triangle, give it a coloured background, restyle the cursor. The default look is plain on purpose; CSS is the design layer. The semantics travel along regardless of styling.
A common mistake: putting interactive content inside the summary. The summary itself is the click target. Adding a <button> inside the summary creates two nested click targets, which trips up keyboard users.
<!-- broken: the inner button never receives clicks reliably --> <details> <summary> Settings <button>Reset</button> </summary> ... </details>
Move the button into the body of the details, not the summary.
<details>. A reviewer says "we should add JavaScript so users can click a 'collapse all' button". What's the smallest correct change?dialog, the modal you don't have to build
Modals are the classic "I'll just write a div" mistake. Pages of code, focus traps, escape-key handlers, scrim management, ARIA dance. The <dialog> element ships almost all of that.
<dialog id="confirm">
<form method="dialog">
<p>Delete this item? This cannot be undone.</p>
<menu>
<button value="cancel">Cancel</button>
<button value="delete">Delete</button>
</menu>
</form>
</dialog>
<button onclick="document.getElementById('confirm').showModal()">
Delete item
</button>When you call showModal(), the browser:
- Renders the dialog in the top layer — above every other element on the page, regardless of
z-index. - Adds a scrim behind it via the
::backdroppseudo-element (style it with CSS). - Makes everything outside the dialog inert — clicks are absorbed, the keyboard cannot reach it.
- Traps focus inside the dialog, looping back when the user tabs past the last control.
- Closes on Escape.
The <form method="dialog"> is the trick that makes the buttons close the dialog. When a button inside that form is clicked, the dialog closes and you can read the clicked button's value from the returnValue property — Cancel returns "cancel", Delete returns "delete".
dialog {
border: none;
border-radius: 8px;
padding: 1.5rem;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.4);
}You have full styling control. The ::backdrop pseudo-element is the scrim layer behind the dialog — set it to whatever opacity and blur fits your design.
There is also dialog.show() — same dialog, but non-modal: the page behind stays interactive, no focus trap, no scrim. Use it for non-blocking panels (a properties side-panel, a date picker). showModal() is what you usually want for confirm-this kind of dialogs.
Because the dialog renders in the top layer, it always sits above every other element. You almost never need to think about z-index. If your dialog is appearing below other elements, you forgot to call showModal() — opening it with the open attribute alone gives you a non-modal panel without the top-layer treatment.
progress vs meter
Two visual indicators that look similar but mean different things.
<progress> shows progress toward completion. A file upload, a multi-step form completion, a download. The bar grows as the task advances.
<label for="upload">Uploading...</label> <progress id="upload" value="60" max="100"></progress>
<meter> shows a measurement within a known range. A disk usage bar, a password strength indicator, a quiz score out of 10. The value sits at a point on a fixed scale.
<label for="disk">Disk used</label> <meter id="disk" value="6.2" min="0" max="10" low="3" high="8" optimum="2"> 6.2 GB of 10 GB </meter>
The difference is direction. Progress moves; meter sits still and reports. A progress bar at 100% means "done". A meter at 100% means "at the high end of its range" — which might be good (water tank full) or bad (disk full), and the low/high/optimum attributes hint to the browser which.
output, the result of a calculation
<output> is a small element with a tightly scoped purpose: hold the result of a calculation or user interaction. Screen readers automatically announce changes to its text, so when the result updates, the user is notified.
<form oninput="result.value = +a.value + +b.value"> <input type="number" id="a" value="10"> + <input type="number" id="b" value="5"> = <output id="result" name="result" for="a b">15</output> </form>
As the user changes either input, the form's oninput handler runs, updating the output. The browser announces the new value. The for attribute lists the inputs the result depends on — assistive tech uses it to explain the relationship.
This is the kind of element you might never have heard of but which removes a chunk of aria-live boilerplate every time you reach for it.
popover, the new kid
The newest landmark widget: the popover API. Any element with the popover attribute becomes a popover — a transient panel that floats above the page, dismisses on outside click and Escape, and renders in the top layer (like dialog).
<button popovertarget="menu">Account</button> <div id="menu" popover> <ul> <li><a href="/profile">Profile</a></li> <li><a href="/settings">Settings</a></li> <li><a href="/logout">Sign out</a></li> </ul> </div>
The button has popovertarget="menu", which wires it to the popover with that id. Click the button and the menu opens, anchored to the button. Click outside or press Escape and it closes. No JavaScript.
popover="auto" is the default — auto-dismisses on outside click. popover="manual" stays open until you call el.hidePopover().
The popover API is supported in every recent evergreen browser. For older browsers, a JavaScript-only fallback is the safety net.
The difference between <dialog> and a popover is intent. A modal dialog blocks the page until the user makes a decision. A popover is a transient panel — a menu, a tooltip, a date picker. Both render in the top layer; only the dialog blocks.
<div>, aria-expanded, a click handler, and a keydown handler for Enter and Space. They argue this is "more flexible" than <details>. What's the strongest counter-argument?