../

Interactive elements

The interactive HTML you can use with little or no JavaScript as of September 2026: disclosure widgets, dialogs, popovers, invoker commands, inert, editable regions, templates and web components, with Baseline status from MDN and webstatus.dev. Styling hooks for these are in CSS and Animation, event handling in Events, and the ARIA side in Accessibility.

Native first

Every native control below ships its role, state, focus behavior and keyboard support. The ARIA version from the APG (opens in a new tab) is a spec for what you must rebuild by hand.

NeedNativeARIA pattern you'd otherwise buildWhat you'd have to write
Show/hide a section<details>/<summary>Disclosure: button[aria-expanded][aria-controls]state sync, toggle JS
Accordion (one open)<details name="x">Accordion: headings wrapping buttonsstate sync, closing siblings
Modal dialog<dialog> + showModal()role="dialog" + aria-modalfocus trap, inert on the page, Esc, focus return, stacking
Non-modal overlay, menu of links[popover] + popovertargetdisclosure button + positioned paneloutside-click and Esc dismissal, z-index, focus return
Tooltippopover="hint" (limited)role="tooltip" + aria-describedbyhover/focus timers, Esc, hoverable content
Button<button>role="button" + tabindex="0"Enter and Space handlers, disabled state
Toggle<input type="checkbox">button[aria-pressed] or role="switch"state, keyboard
Select one of a fewradio group in a <fieldset>role="radiogroup"roving tabindex, arrow keys
Select from a list<select>Listbox or select-only comboboxtypeahead, arrow keys, popup
Suggestions while typing<input list> + <datalist>Combobox with listbox popupthe hardest APG pattern to get right
Slider<input type="range">role="slider"arrows, Page Up/Down, Home/End, aria-valuenow
Progress, gauge<progress>, <meter>role="progressbar", role="meter"value attributes
TabsnoneTabs: tablist/tab/tabpanelarrow keys, roving tabindex, panel wiring

Tabs, menus (role="menu", application-style), trees and grids have no native element; those are the cases where ARIA plus JavaScript is the right tool.

Keyboard behavior you get for free

ElementKeys
<a href>Tab to focus, Enter to follow
<button>Enter activates; Space activates on key release; disabled removes it from the tab order
<summary>Enter or Space toggles its <details>
<dialog> (modal)focus moves inside; Tab stays in the dialog and the browser UI; Esc closes; focus returns to the opener
[popover] (auto)the popover follows its invoker in tab order; Esc closes and returns focus to the invoker
CheckboxSpace toggles
Radio groupone tab stop; arrow keys move and select
<select>arrows change, typing jumps to a match, Space or Alt+↓ opens (varies by OS)
<input type="range">arrows step, Page Up/Down jump, Home/End go to min/max
<input type="number">↑/↓ step
contenteditable, <textarea>full text editing, undo, IME, spellcheck

<details> & <summary>

<details>
  <summary>Shipping costs</summary>
  <p>Free over €50; otherwise €4.90 in the EU.</p>
</details>
FeatureDetailSupport
openpresent = expanded; toggle it from JS or let the user clickwidely available
toggle eventfires after the state changes (event.newState, oldState); not cancelable, and several quick changes coalesce into onewidely available
name="group"exclusive accordion: opening one closes the others with the same nameBaseline 2024 (September)
::details-contentthe panel wrapping everything except <summary>, so it can be styled and animatedBaseline 2025 (September)
:openmatches open details, dialog, select and inputs with an open pickerBaseline 2026 (May)
summary::markerthe disclosure triangleolder Safari also needs ::-webkit-details-marker
Find in pageChromium opens a closed <details> when find-in-page matches its content
summary { cursor: pointer; }
summary::marker { color: var(--accent); }
 
/* custom icon instead of the triangle */
summary { list-style: none; }
summary::-webkit-details-marker { display: none; }
summary::after { content: "+"; margin-inline-start: 1ch; }
details[open] > summary::after { content: "−"; }
 
details::details-content { padding-block: 0.5rem; }

Animating the open and close height with ::details-content and interpolate-size is in Animation.

  • <summary> must be the first child; without one the browser supplies "Details".
  • <summary> is exposed as a button-like disclosure control with an expanded state. Put only text (optionally a heading) inside: no links, buttons or inputs.
  • A heading inside <summary> works in markup, but some screen readers flatten it into the button, so it may not appear in the headings list. If heading navigation matters, put the heading before the <details>.
  • Exclusive accordions stop users opening two answers to compare them. Use name only when one-at-a-time genuinely helps.

<dialog>

<button type="button" commandfor="del" command="show-modal">
  Delete project
</button>
 
<dialog id="del" aria-labelledby="del-title">
  <h2 id="del-title">Delete "Atlas"?</h2>
  <p>This removes 14 files. It can't be undone.</p>
  <form method="dialog">
    <button value="cancel" autofocus>Cancel</button>
    <button value="delete">Delete</button>
  </form>
</dialog>
APIDoes
dialog.showModal()modal: top layer, ::backdrop, the rest of the page inert, Esc closes, implicit aria-modal="true"
dialog.show()non-modal: stays in normal stacking, page stays interactive, Esc does nothing by default
dialog.close(value?)closes; sets returnValue if a value is given; fires close
dialog.requestClose(value?)fires a cancelable cancel first, then closes; Baseline 2025 (May)
dialog.returnValuethe value of the button that submitted a method="dialog" form
open attributereflects state; setting it by hand shows a non-modal dialog without the focus handling. Don't
cancel eventon Esc or requestClose(); preventDefault() keeps it open, but browsers let a second Esc through if the user hasn't interacted in between
close eventafter any close; read returnValue here
<form method="dialog">submitting closes the dialog without a network request; form state is kept
formmethod="dialog"the same on one button of a normal form
closedbyany (Esc, light dismiss and code), closerequest (Esc and code; the default for modals), none (code only; the default for show())

Focus

WhenWhat happensWhat you do
Openingthe browser focuses the element with autofocus, else the first focusable descendantput autofocus on the least destructive action or the first field; for long text, on the dialog itself
Insidea modal's page behind it is inert; Tab cycles through the dialog and the browser chromenothing
Closingfocus returns to the element that was focused before openingmake sure that element still exists; if not, move focus somewhere sensible yourself
  • Never put tabindex on the <dialog> element (MDN); focus its contents.
  • Name it: aria-labelledby pointing at its heading.
  • Lock page scroll behind a modal with html:has(dialog:modal) { overflow: hidden; }.
  • Only one modal at a time where possible. Stacked modals work (each Esc closes the top one), but they are rarely good UX.
dialog.html
<style>
  dialog { border: 0; border-radius: 10px; padding: 16px;
    background: var(--bg); color: var(--fg);
    box-shadow: 0 10px 30px rgb(0 0 0 / 35%); }
  dialog::backdrop { background: rgb(0 0 0 / 45%); }
  .row { display: flex; gap: 8px; justify-content: end; }
  button { font: inherit; padding: 6px 12px; }
</style>
<button commandfor="d1" command="show-modal">
  Open modal (no JavaScript)
</button>
<dialog id="d1" aria-labelledby="d1-t">
  <h3 id="d1-t" style="margin-top:0">Discard draft?</h3>
  <p>Your changes will be lost.</p>
  <div class="row">
    <button commandfor="d1" command="close" autofocus>
      Keep editing</button>
    <button commandfor="d1" command="close">Discard</button>
  </div>
</dialog>
Result

The demo frame has scripts disabled: the dialog opens and closes through invoker commands alone (Chrome 135+, Firefox 144+, Safari 26.2+).

Popover API

<button popovertarget="share">Share</button>
<div id="share" popover>
  <a href="mailto:?body=…">Email</a>
  <button type="button">Copy link</button>
</div>

The Popover API is Baseline 2025 (January, when iOS Safari 18.3 caught up; desktop engines had it by April 2024). Any element with popover is hidden until shown, then rendered in the top layer above everything, unaffected by ancestors' overflow or z-index.

ValueLight dismiss (outside click, Esc)Closes othersUseSupport
auto (or empty)yesother auto popovers, except its ancestors (nesting works)menus, pickers, share sheets, teaching bubblesBaseline 2025
manualnononetoasts, persistent panels; you close themBaseline 2025
hintyesother hint popovers; leaves auto popovers opentooltips and previews shown on hover or focuslimited: Chromium and Firefox, not Safari

An unrecognised value falls back to manual (the spec's invalid-value default), so in Safari popover="hint" shows but never light-dismisses. Give it a close path of its own.

Attribute / APIDoes
popovertarget="id"on a <button> or <input type="button">: toggles that popover
popovertargetactiontoggle (default), show, hide
el.showPopover({ source }), hidePopover(), togglePopover()the same from JS
beforetoggle eventbefore the change; preventDefault() cancels opening only
toggle eventafter; newState and oldState are "open" / "closed"; event.source is the invoker (Baseline 2026)
:popover-openCSS for the open state
::backdropalso exists for popovers; transparent by default

What the invoker relationship gives you:

  • An implicit aria-expanded on the button and an aria-details link to the popover, when it isn't the next element anyway.
  • Tab order continues from the invoker into the popover, wherever the popover sits in the DOM.
  • Esc returns focus to the invoker (a click outside leaves focus where the user clicked).
  • An implicit anchor for CSS anchor positioning, so position-area works with no anchor-name (see Anchor positioning).

A popover is not modal: nothing is inert, focus isn't trapped and there is no scroll lock. If users must answer before continuing, use <dialog> with showModal(). The two combine: <dialog popover> is a light-dismissable non-modal dialog.

popover.html
<style>
  [popover] { margin: 0; padding: 6px; border: 1px solid
      var(--muted); border-radius: 8px;
    background: var(--bg); color: var(--fg);
    inset: auto; top: 48px; left: 12px; }
  [popover] a { display: block; padding: 6px 12px;
    color: inherit; border-radius: 4px; }
  [popover] a:hover, [popover] a:focus-visible {
    background: var(--chip); }
  button { font: inherit; padding: 6px 12px; }
</style>
<button popovertarget="m1">Account ▾</button>
<div id="m1" popover>
  <a href="#profile">Profile</a>
  <a href="#billing">Billing</a>
  <a href="#out">Sign out</a>
</div>
<p style="margin-top:110px">Click outside or press Esc to
  close.</p>
Result

The demo pins the popover with top/left so it works everywhere; in production, position it against the button with anchor positioning (Chrome 125+, Firefox 147+, Safari 26+).

Invoker commands

commandfor + command on a <button> wire it to another element declaratively: a more general popovertarget. Baseline 2025 (December): Chrome 135, Firefox 144, Safari 26.2.

commandTargetEquivalent
show-modal<dialog>showModal()
close<dialog>close(); the button's value becomes returnValue
request-close<dialog>requestClose(): fires cancelable cancel, then closes
show-popover[popover]showPopover()
hide-popover[popover]hidePopover()
toggle-popover[popover]togglePopover()
--anythingany elementfires a command event on the target; you handle it

Custom commands must start with two hyphens. The target receives a CommandEvent with command and source:

<button commandfor="player" command="--rewind">
  Back 10 s
</button>
<button commandfor="player" command="--play">Play</button>
<video id="player" src="talk.mp4"></video>
const video = document.querySelector<HTMLVideoElement>(
  "#player",
)!;
 
video.addEventListener("command", (event) => {
  const { command } = event as CommandEvent;
  if (command === "--play") void video.play();
  if (command === "--rewind") video.currentTime -= 10;
});
  • One listener on the target, no per-button handlers, and it works for buttons added later or inside other components' markup (not across shadow roots by id, though).
  • commandfor only works on <button>. Inside a <form>, always write type="button": under the current HTML spec an untyped command button in a form does nothing at all.
  • Interest invokers (interestfor, showing a popover on hover or focus) are Chromium only (limited availability) as of September 2026.

inert

inert on an element removes it and its subtree from the interaction model: no focus, no clicks, no find-in-page, no text selection, hidden from assistive tech. Baseline widely available (October 2025).

<main inert>…</main>  <!-- behind a custom overlay -->
<nav class="drawer" inert>…</nav>  <!-- closed off-canvas -->
UseInstead of
Closed off-canvas menu that stays in the DOM for its slide animationtabindex="-1" on every link
Content behind a non-<dialog> overlaya JS focus trap
Off-screen carousel slidesaria-hidden plus tabindex juggling
A form section that doesn't apply yetdisabled on each field, when you want it visible but dimmed
  • showModal() makes the rest of the page inert for you; don't add it manually there.
  • Inert content still looks interactive. Dim it ([inert] { opacity: 0.5; }) or hide it, or sighted keyboard users see controls they can't reach.
  • Never make the element that currently has focus inert without moving focus first.

hidden="until-found"

Hides content that find-in-page and fragment links (#id) can still reach. On a match the browser fires beforematch on the element, removes hidden, then scrolls to it.

<h3 id="q-refunds">
  <button aria-expanded="false" aria-controls="a-refunds">
    How do refunds work?
  </button>
</h3>
<div id="a-refunds" hidden="until-found">…</div>
const panel = document.getElementById("a-refunds")!;
panel.addEventListener("beforematch", () => {
  // keep the button's state in sync with the reveal
  document.querySelector("[aria-controls=a-refunds]")
    ?.setAttribute("aria-expanded", "true");
});
PointDetail
Mechanismcontent-visibility: hidden, not display: none: the box keeps its margins, border and padding
Breaks withdisplay: none, contents or inline on the element: then it is never revealed
Supportlimited: Chromium 102+ and Firefox 148+, not Safari. Elsewhere it acts like plain hidden
vs <details><details> gets similar find-in-page behavior in Chromium with no JS; use until-found for custom accordions and tabs

contenteditable

ValueBehaviorSupport
true or emptyrich editing: paste keeps formatting, Ctrl+B boldswidely available
plaintext-onlyeditable text; pasted formatting is dropped, no rich shortcutsBaseline 2025 (March)
falsenot editable, even inside an editable parentwidely available
(absent)inherits from the parent
contenteditable.html
<style>
  .edit { padding: 8px; border: 1px dashed var(--muted);
    border-radius: 6px; min-block-size: 3lh; }
  .edit:focus-visible { outline: 2px solid var(--graph-0); }
</style>
<p id="note-l">Note (plain text only):</p>
<div class="edit" contenteditable="plaintext-only"
  role="textbox" aria-multiline="true"
  aria-labelledby="note-l">Click and type; paste loses
  formatting.</div>
Result
  • An editable div has no role or name: add role="textbox", aria-multiline="true" and a label. For a form field, a <textarea> with field-sizing: content is usually the better answer.
  • Its content is not submitted with a form; copy it into a hidden input on submit or use a form-associated custom element.
  • Pasted rich HTML is untrusted: sanitize before saving or rendering elsewhere.
  • Rich-text editors (ProseMirror, Lexical and similar) exist because contenteditable="true" behaves differently across browsers; don't build one from scratch.

<template>

<template> holds inert markup: it is parsed but not rendered, scripts don't run and images don't load until you clone it.

<template id="row-tpl">
  <li class="row">
    <span class="name"></span>
    <button type="button" class="remove">Remove</button>
  </li>
</template>
<ul id="list"></ul>
const tpl = document.querySelector<HTMLTemplateElement>(
  "#row-tpl",
)!;
 
function addRow(name: string): void {
  const frag = tpl.content.cloneNode(
    true,
  ) as DocumentFragment;
  // textContent, not innerHTML
  frag.querySelector(".name")!.textContent = name;
  document.querySelector("#list")!.append(frag);
}
  • template.content is a DocumentFragment; cloning is cheaper than building nodes one by one and safer than innerHTML with interpolated strings.
  • Fill slots with textContent, never string-concatenated HTML.
  • <template shadowrootmode> is a different job: declarative shadow DOM, below.

Custom elements

class CopyButton extends HTMLElement {
  static observedAttributes = ["text"];
 
  connectedCallback(): void {
    this.innerHTML = `<button type="button">Copy</button>`;
    this.querySelector("button")!.addEventListener(
      "click",
      () => navigator.clipboard.writeText(this.text),
    );
  }
 
  get text(): string {
    return this.getAttribute("text") ?? "";
  }
}
 
customElements.define("copy-button", CopyButton);
KindDeclared asUsed asSupport
Autonomousclass X extends HTMLElement<copy-button>widely available
Customized built-inclass X extends HTMLButtonElement + define("fancy-btn", X, { extends: "button" })<button is="fancy-btn">limited: Chromium and Firefox. MDN notes Safari does not plan to support them

Because of Safari, build autonomous elements. To keep native semantics, wrap a real <button> or <input> inside (light or shadow DOM) rather than extending it.

Lifecycle callbackFires
constructor()when an instance is created or upgraded; call super() first; don't read attributes or add children here
connectedCallback()each time the element is inserted into a document; do setup here
disconnectedCallback()each time it is removed; remove global listeners, observers, timers
connectedMoveCallback()instead of the two above when moved with moveBefore() (limited availability)
adoptedCallback()moved to a new document (e.g. into an iframe)
attributeChangedCallback(name, old, value)an attribute in static observedAttributes is added, changed or removed; also once for each initial value at parse time
RuleDetail
Nameslowercase first letter, at least one hyphen: x-tabs, app-card
Register oncedefine throws on a duplicate name; check customElements.get(name) first
Upgradeelements in the HTML before define runs are upgraded later; customElements.whenDefined(name) resolves then
Before upgradestyle with :not(:defined) to avoid a flash of unstyled content
Attributes vs propertiesattributes are strings in HTML; mirror the ones that matter as properties
Custom statesinternals.states.add("loading") then :state(loading) in CSS (Baseline 2024)
Scoped registriesper-shadow-root registries are limited availability; name elements with a prefix to avoid clashes

Shadow DOM

Shadow DOM gives an element a private DOM subtree whose styles and ids don't leak in or out.

class UserCard extends HTMLElement {
  constructor() {
    super();
    const root = this.attachShadow({ mode: "open" });
    root.innerHTML = `
      <style>
        :host { display: block; border: 1px solid; }
        :host([compact]) { padding: 0.25rem; }
        ::slotted(img) { border-radius: 50%; }
      </style>
      <slot name="avatar"></slot>
      <h3 part="title">
        <slot name="name">Anonymous</slot>
      </h3>
      <slot></slot>`;
  }
}
customElements.define("user-card", UserCard);
ConceptDetail
mode: "open"el.shadowRoot exposes the root to page scripts; the normal choice
mode: "closed"el.shadowRoot is null, but it is not a security boundary; mostly just makes testing harder
delegatesFocus: trueclicking the host focuses the first focusable element inside; :focus matches the host
<slot> / <slot name="x">where light-DOM children render; children with slot="x" go to the named slot, the rest to the default
Slot fallbackcontent inside <slot> shows when nothing is slotted
::slotted(sel)styles slotted top-level children (compound selectors only)
:host, :host(.x)style the element itself from inside; outside styles on the host win
::part(name)styles an inner element marked part="name" from outside
What crosses ininherited properties (color, font) and custom properties; nothing else
adoptedStyleSheetsshare one CSSStyleSheet object across many roots

Declarative shadow DOM

<template shadowrootmode="open"> makes the shadow root at parse time: server-rendered components paint before JS loads, and work with JS off. Baseline widely available since August 2026.

declarative-shadow-dom.html
<article class="card">
  <template shadowrootmode="open">
    <style>
      :host { display: block; max-inline-size: 300px;
        padding: 12px; border-radius: 10px;
        border: 1px solid var(--muted); }
      h3 { margin: 0 0 4px; color: var(--graph-0); }
      ::slotted(p) { margin: 0; }
    </style>
    <h3><slot name="title">Untitled</slot></h3>
    <slot></slot>
  </template>
  <span slot="title">Declarative shadow DOM</span>
  <p>Rendered by the HTML parser: this frame has no
    JavaScript at all.</p>
</article>
Result
AttributeEquivalent
shadowrootmode="open" / "closed"attachShadow({ mode })
shadowrootdelegatesfocusdelegatesFocus: true
shadowrootclonableclonable: true: cloneNode copies the shadow root
shadowrootserializableserializable: true: getHTML({ serializableShadowRoots: true }) includes it
  • Only the HTML parser processes it. innerHTML doesn't; use setHTMLUnsafe() or Document.parseHTMLUnsafe() to parse markup that contains it.
  • A custom element that may be server-rendered should check this.shadowRoot before calling attachShadow(), or it throws.

Form-associated custom elements

static formAssociated = true plus attachInternals() make a custom element a real form control: it submits a value, takes part in validation, reset and disabled fieldsets, and gets <label> support. Baseline widely available (September 2025).

class StarRating extends HTMLElement {
  static formAssociated = true;
  #internals = this.attachInternals();
  #value = 0;
 
  connectedCallback(): void {
    this.#internals.role = "slider";
    this.#internals.ariaValueMin = "0";
    this.#internals.ariaValueMax = "5";
    this.tabIndex = 0;
    this.addEventListener("keydown", this.#onKey);
    this.#update();
  }
 
  #onKey = (e: KeyboardEvent): void => {
    if (e.key === "ArrowRight") this.value = this.#value + 1;
    if (e.key === "ArrowLeft") this.value = this.#value - 1;
  };
 
  set value(v: number) {
    this.#value = Math.min(5, Math.max(0, v));
    this.#update();
  }
 
  #update(): void {
    const v = String(this.#value);
    this.#internals.setFormValue(v);
    this.#internals.ariaValueNow = v;
    if (this.hasAttribute("required") && !this.#value) {
      this.#internals.setValidity(
        { valueMissing: true }, "Pick a rating", this,
      );
    } else {
      this.#internals.setValidity({});
    }
  }
 
  formResetCallback(): void { this.value = 0; }
}
customElements.define("star-rating", StarRating);
ElementInternalsDoes
setFormValue(value, state?)the submitted value (string, File or FormData)
setValidity(flags, message, anchor)constraint validation; anchor is where the browser's bubble points
form, labels, validity, checkValidity(), reportValidity()as on native controls
role, ariaLabel, ariaChecked, …default semantics without sprouting attributes on the host; page authors can still override with attributes
statescustom states for :state()
CallbackFires
formAssociatedCallback(form)the element joins or leaves a form
formDisabledCallback(disabled)it or an ancestor <fieldset> is disabled; mirror it (e.g. set tabIndex = -1)
formResetCallback()the form resets
formStateRestoreCallback(state, reason)the browser restores state (back/forward, autofill)

You still owe the keyboard support: ElementInternals exposes semantics, it doesn't implement behavior.

Common mistakes

MistakeWhy it failsFix
<div onclick> as a buttonno focus, no Enter/Space, no role<button type="button">
<a href="#"> or <a> without href as a buttonlinks navigate; Space doesn't activate them<button>; links are for URLs
Nested interactive content (<button> in <a>, a link in <summary>, a button in a <label>)invalid HTML; clicks and names become unpredictableone interactive element per target
Dialog without a visible close pathkeyboard and touch users get stuck; closedby="none" removes Esca close or cancel button, always
Toggling dialog.open = true for a modalno top layer, no inert page, no EscshowModal() or command="show-modal"
tabindex on <dialog>the dialog isn't meant to be focused; it confuses the initial focusautofocus on the right child
autofocus on a destructive buttonEnter confirms by accidentautofocus Cancel or the first field
Popover for a required decisionnothing is inert; users can ignore itmodal <dialog>
Hover-only popover or tooltipno keyboard or touch accesstrigger with a button, or show on focus as well as hover
role="menu" on a navigation dropdownscreen readers switch to application-menu mode and expect arrow keysa list of links in a popover or <details>
aria-expanded added to a popovertarget buttonduplicates the implicit stateleave it off
Customized built-ins (is="…")never works in Safariautonomous element wrapping a native control
Label in the page, input in a shadow rootids don't cross the boundaryform-associated element or keep them together
hidden="until-found" with display: none on the same elementnever revealedleave display alone

Recipes

FAQ accordion

Exclusive, keyboard-accessible, searchable in Chromium, zero JavaScript:

faq.html
<style>
  .faq details { border-block-end: 1px solid var(--chip); }
  .faq summary { padding: 10px 4px; cursor: pointer;
    font-weight: 600; list-style: none; }
  .faq summary::-webkit-details-marker { display: none; }
  .faq summary::before { content: "+"; display: inline-block;
    inline-size: 1.5ch; color: var(--graph-0); }
  .faq details[open] summary::before { content: "−"; }
  .faq p { margin: 0 0 10px 1.5ch; }
</style>
<div class="faq">
  <details name="faq" open>
    <summary>Can I cancel anytime?</summary>
    <p>Yes. Your plan runs to the end of the period.</p>
  </details>
  <details name="faq">
    <summary>Do you offer refunds?</summary>
    <p>Within 14 days of purchase, no questions asked.</p>
  </details>
  <details name="faq">
    <summary>Is there a student discount?</summary>
    <p>50% with a valid university email.</p>
  </details>
</div>
Result

Put the question in a heading before each <details> if the page is long enough that people navigate FAQs by headings.

Confirm dialog with form method="dialog"

<button type="button" commandfor="confirm"
  command="show-modal">Delete account</button>
 
<dialog id="confirm" aria-labelledby="confirm-t"
  closedby="any">
  <form method="dialog">
    <h2 id="confirm-t">Delete your account?</h2>
    <p>All projects are removed after 30 days.</p>
    <button value="cancel" autofocus>Cancel</button>
    <button value="delete" class="danger">Delete</button>
  </form>
</dialog>
const dialog = document.querySelector<HTMLDialogElement>(
  "#confirm",
)!;
 
dialog.addEventListener("close", () => {
  // "" when closed by Esc or light dismiss
  if (dialog.returnValue === "delete") void deleteAccount();
  dialog.returnValue = "";
});

Reset returnValue after reading it: it survives the next Esc close, which doesn't overwrite it.

Popover menu anchored to its button

<button popovertarget="more" class="more-btn">
  More actions
</button>
<div id="more" popover class="menu">
  <a href="/export/">Export</a>
  <a href="/archive/">Archive</a>
  <a href="/settings/">Settings</a>
</div>
.menu {
  margin: 0;
  inset: auto;
  /* the invoker is the implicit anchor */
  position-area: block-end span-inline-end;
  position-try-fallbacks: flip-block, flip-inline;
  margin-block-start: 0.25rem;
}
.menu:popover-open { display: grid; }

Tooltip with popover="hint"

A hint popover doesn't close an open menu. Show it on hover and focus, hide it on leave, blur and Esc (Esc is built in where hint is supported):

<button type="button" id="save" aria-label="Save"
  aria-describedby="tip-save">
  <svg aria-hidden="true" width="16" height="16">
    <use href="/icons.svg#save" />
  </svg>
</button>
<div id="tip-save" popover="hint" role="tooltip">
  Save (Ctrl+S)
</div>
const btn = document.querySelector<HTMLElement>("#save")!;
const tip = document.querySelector<HTMLElement>(
  "#tip-save",
)!;
const show = () => tip.showPopover({ source: btn });
const hide = () => tip.hidePopover();
 
btn.addEventListener("pointerenter", show);
btn.addEventListener("focus", show);
btn.addEventListener("pointerleave", hide);
btn.addEventListener("blur", hide);
  • The button's accessible name must not depend on the tooltip: give an icon button an aria-label and use the tooltip as the description.
  • WCAG 1.4.13 wants the tooltip hoverable (don't hide it when the pointer moves onto it), dismissible without moving the pointer, and persistent until dismissed. Add a short hide delay and cancel it on pointerenter of the tooltip.
  • Where hint is unsupported it falls back to manual, so the JS above still shows and hides it; only Esc needs a fallback listener.
  • In Chromium, interestfor="tip-save" on the button does all of this declaratively; it is not yet in other engines.

A tiny custom element with a slot

const sheet = new CSSStyleSheet();
sheet.replaceSync(`
  :host { display: flex; gap: .5rem; align-items: center;
    padding: .5rem .75rem; border-radius: .5rem;
    background: var(--callout-bg, #eef); }
  ::slotted(strong) { color: var(--callout-fg, navy); }
`);
 
class NoteCallout extends HTMLElement {
  connectedCallback(): void {
    if (this.shadowRoot) return; // declaratively rendered
    const root = this.attachShadow({ mode: "open" });
    root.adoptedStyleSheets = [sheet];
    root.innerHTML = `<span aria-hidden="true">i</span>
      <div><slot>Nothing to say.</slot></div>`;
  }
}
customElements.define("note-callout", NoteCallout);
<note-callout>
  <strong>Heads up:</strong> maintenance on Sunday.
</note-callout>

The text stays in the light DOM, so it is indexed, found by find-in-page and read by screen readers in place.

Declarative shadow DOM card

Server-rendered markup that is already encapsulated when it arrives; a script can upgrade it later:

<profile-card>
  <template shadowrootmode="open">
    <style>
      :host { display: grid; grid-template-columns: auto 1fr;
        gap: 0 1rem; align-items: center; }
      ::slotted(img) { grid-row: span 2;
        border-radius: 50%; }
      ::slotted(h3) { margin: 0; }
    </style>
    <slot name="photo"></slot>
    <slot name="name"></slot>
    <slot></slot>
  </template>
  <img slot="photo" src="ana.jpg" alt="" width="64"
    height="64">
  <h3 slot="name">Ana Diaz</h3>
  <p>Staff engineer, accessibility team</p>
</profile-card>

The photo's alt is empty because the name follows it; the heading stays in the light DOM, so it appears in the page's heading outline.

References