Skip to content

COMPONENT

Dropdown

A button that reveals a short list of choices. No JavaScript: the keyboard, Escape, the click-outside and the announced state all belong to the platform.

A language chooser

Open it with Enter. Close it with Escape, or by clicking anywhere else. Tab walks the options.

HTML
<div class="dropdown">
  <button class="dropdown-button" popovertarget="ds-langs">
    <svg class="flag" viewBox="0 0 3 2" aria-hidden="true">
      <rect width="3" height="1" fill="#fff"/><rect y="1" width="3" height="1" fill="#dc143c"/>
    </svg>
    <span lang="pl">Polski</span>
    <span class="caret" aria-hidden="true">▾</span>
  </button>

  <div class="dropdown-panel" id="ds-langs" popover>
    <a class="menu-item" href="#pl" lang="pl" aria-current="true">
      <svg class="flag" viewBox="0 0 3 2" aria-hidden="true">
        <rect width="3" height="1" fill="#fff"/><rect y="1" width="3" height="1" fill="#dc143c"/>
      </svg>
      Polski
    </a>
    <a class="menu-item" href="#es" lang="es">
      <svg class="flag" viewBox="0 0 3 2" aria-hidden="true">
        <rect width="3" height="2" fill="#c60b1e"/><rect y="0.5" width="3" height="1" fill="#ffc400"/>
      </svg>
      Español
    </a>
    <span class="menu-item" lang="de" aria-disabled="true" title="Not translated yet.">
      <svg class="flag" viewBox="0 0 3 2" aria-hidden="true">
        <rect width="3" height="2" fill="#000"/><rect y="0.667" width="3" height="1.333" fill="#dd0000"/>
        <rect y="1.333" width="3" height="0.667" fill="#ffce00"/>
      </svg>
      Deutsch
    </span>
  </div>
</div>

Two of them, on one row

Open one, then the other. Each panel lands under the button that opened it — which is not free, and is the reason for the paragraph below.

HTML
<div class="dropdown">
  <button class="dropdown-button" popovertarget="ds-sort" aria-label="Sort order">
    <span>Newest first</span>
    <span class="caret" aria-hidden="true">▾</span>
  </button>

  <div class="dropdown-panel" id="ds-sort" popover>
    <a class="menu-item" href="#newest" aria-current="true">Newest first</a>
    <a class="menu-item" href="#oldest">Oldest first</a>
    <a class="menu-item" href="#title">By title</a>
  </div>
</div>

<div class="dropdown">
  <button class="dropdown-button" popovertarget="ds-shown" aria-label="How many to show">
    <span>25 per page</span>
    <span class="caret" aria-hidden="true">▾</span>
  </button>

  <div class="dropdown-panel" id="ds-shown" popover>
    <a class="menu-item" href="#ten">10 per page</a>
    <a class="menu-item" href="#twentyfive" aria-current="true">25 per page</a>
    <a class="menu-item" href="#hundred">100 per page</a>
  </div>
</div>

Reference

Read from components/dropdown.css when this page was built, so it cannot fall behind the stylesheet.

CLASSES.dropdown.dropdown-button.dropdown-panel
SETTINGSSet the first on one element; a theme sets the second for all of them.--dropdown-shadow — falls back to --ds-shadow-floating
READS--ds-accent--ds-focus-ring--ds-focus-width--ds-font-display--ds-gap-inline--ds-gap-tight--ds-hairline--ds-hit-min--ds-ink--ds-ink-soft--ds-motion-fast--ds-pad-chip--ds-radius-control--ds-raised--ds-rule--ds-rule-strong--ds-sunk--ds-text-ui--ds-track-subtle

Notes

AN ANCHOR NAME IS GLOBAL UNLESS IT IS SCOPED

Every dropdown in the library spells the same anchor name, and position-anchor resolves to the last element in the document carrying it. So a header holding a language chooser beside a menu button handed both panels to the second button, and one of them opened several hundred pixels from the control that opened it. The wrapper carries anchor-scope, which confines the name to its own subtree — that is what makes one name reusable instead of a collision waiting for the second instance, and it is why the button and the panel must both sit inside .dropdown.

THERE IS NO SCRIPT BEHIND THIS

popover on the panel and popovertarget on the button is the entire mechanism.Enter and Space toggle it, Escape closes it, a click outside closes it, focus moves in and returns to the button — and the button reports itself to assistive technology as expanded or collapsed with no ARIA attribute in the markup at all. That last claim was read out of the accessibility tree rather than taken on faith: the invoker reports expanded=false closed and expanded=true open, identically to a button carrying aria-expanded by hand.

A DISCLOSURE, NOT AN ARIA MENU

The panel holds links, so Tab walks them and that is the whole keyboard contract. Do not reach for role="menu" and role="menuitem": those promise arrow-key roving focus, which no stylesheet can deliver, and a promise made in ARIA and broken in behaviour is worse than the plain markup it replaced.

WHEN TO USE SEGMENTED INSTEAD

A dropdown trades visibility for room, and earns that trade when the options are many or the row has run out of space. Two or three options that already fit are a .segmented control — every choice visible, no click to discover what the choices even are.

THE PANEL IS IN THE TOP LAYER

So overflow: hidden on a header cannot clip it and no stacking context can trap it. The same property is why it is not positioned by its parent: position-anchor is the only thing tying it back to the button, and without it the panel lands in the middle of the window.

A FLAG IS A COUNTRY, AND A LANGUAGE IS NOT

The flags here are aria-hidden decoration beside a written language name, which is the only way to use one honestly. Spanish is not Spain and English is not England, and a reader in Argentina or Ireland is entitled not to feel addressed by somebody else's flag. The library ships none of these images — see .flag for the contract they obey.