Dropdown
Action menu
Intent
Dropdown is an action menu attached to a trigger -- the
"more actions" affordance on a row, a workspace switcher,
a sort-by selector, an account menu in the page header.
It composes on top of popover,
inheriting the floating surface, anchor positioning, and
entry / exit animation; the dropdown layer adds menu chrome
(items, separator, optional icons / shortcuts / labels) and
the small <brio-dropdown> custom element
that wires arrow-key navigation, focus management, and
trigger ARIA state.
Reach for popover for arbitrary floating content (hover cards, help bubbles, mini forms) -- popover doesn't manage focus or keyboard, so it's the right choice when the surface isn't a menu. Reach for sidebar-nav for persistent navigation that lives next to content. Reach for dialog when the action needs to interrupt the user. Dropdown is for transient menus of choices.
Basic usage
A dropdown wraps two things in a
<brio-dropdown> element: a trigger
button that invokes the menu via
commandfor="id" and
command="toggle-popover", and the menu panel
itself -- a .popover.dropdown with
role="menu" and a stable id.
Items are buttons or links with
role="menuitem"; the
.dropdown-item class styles either form
consistently. Separators are bare
<hr role="separator"> elements.
brio uses commandfor + command
across every overlay (dialog, popover, dropdown). The older
popovertarget attribute also works for
invoking popovers, but pick one per trigger -- not
both. Combining them fires two invocations per click and
produces stuck-state bugs across browsers.
<brio-dropdown> finds its trigger by
looking for [commandfor], so the menu's
keyboard navigation, focus management, and trigger ARIA
wiring all key off the same attribute.
<docs-resize>
<brio-dropdown>
<button class="button" type="button" commandfor="dd-basic" command="toggle-popover">Actions</button>
<div class="popover dropdown" id="dd-basic" popover="" role="menu">
<button class="dropdown-item" type="button" role="menuitem">Edit</button>
<button class="dropdown-item" type="button" role="menuitem">Duplicate</button>
<hr role="separator"/>
<button class="dropdown-item is-danger" type="button" role="menuitem">Delete</button>
</div>
</brio-dropdown>
</docs-resize>Open the menu with the trigger; arrow keys move focus between items; Enter or Space activates; Escape closes and returns focus to the trigger. Click outside to dismiss. The custom element wires all of this on top of the native Popover API -- there's no JavaScript in the consumer application.
Slots
.dropdown-item
A single menu row. The class styles either a
<button> (for actions) or an
<a> (for navigational items) -- both
take role="menuitem". Items lay out as a flex
row so a leading icon, growing text, and a trailing
keyboard shortcut all align cleanly.
Disabled items: prefer :disabled on
<button> items; use
aria-disabled="true" for
<a> items, where the
:disabled pseudo-class doesn't apply. Both
forms are skipped by the keyboard navigation and styled
the same.
Icon and shortcut
Optional decorations inside an item, keyed by element. A
first-child
<svg>
or
<img>
inside a .dropdown-item picks up icon styling
-- fixed --icon-size, muted color, inheriting
the row's color in the .is-danger variant. A
<kbd>
inside the item gets pushed to the trailing edge via
margin-inline-start: auto and renders in the
monospace font; use it for keyboard hints.
.dropdown-label
A small uppercase header that groups items below it.
Useful in longer menus where a flat list would be hard to
scan. The label isn't an item; it doesn't take
role="menuitem" and isn't reachable by arrow
keys.
Separator
A bare <hr role="separator">. Either
the <hr> tag or any element with
role="separator" renders as a divider line.
Pure presentational; the role gives it the right semantics
so screen readers announce a divider rather than a
horizontal rule.
File
Sharing
<docs-resize>
<brio-dropdown>
<button class="button" type="button" commandfor="dd-full" command="toggle-popover">File</button>
<div class="popover dropdown" id="dd-full" popover="" role="menu">
<p class="dropdown-label">File</p>
<button class="dropdown-item" type="button" role="menuitem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
New
<kbd>Ctrl+N</kbd>
</button>
<button class="dropdown-item" type="button" role="menuitem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
</svg>
Save
<kbd>Ctrl+S</kbd>
</button>
<hr role="separator"/>
<p class="dropdown-label">Sharing</p>
<button class="dropdown-item" type="button" role="menuitem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3"></circle>
<circle cx="6" cy="12" r="3"></circle>
<circle cx="18" cy="19" r="3"></circle>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
</svg>
Share
</button>
<button class="dropdown-item" type="button" role="menuitem" disabled="">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path>
<polyline points="16 6 12 2 8 6"></polyline>
<line x1="12" y1="2" x2="12" y2="15"></line>
</svg>
Export (disabled)
</button>
<hr role="separator"/>
<button class="dropdown-item is-danger" type="button" role="menuitem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"></path>
</svg>
Delete
</button>
</div>
</brio-dropdown>
</docs-resize>Variants
Item state
Item state binds to ARIA and native attributes per
P5 -- not .is-* classes.
:disabled (button items) and
aria-disabled="true" (link items) make an
item unactivatable and unfocusable for keyboard
navigation. aria-current="true" marks the
currently-active option in menus that act as selectors --
a sort order, a theme picker, a view toggle. The
.is-danger class is the one
.is-* on items because it's a design choice
(destructive action), not user-interactive state.
<docs-resize>
<brio-dropdown>
<button class="button" type="button" commandfor="dd-state" command="toggle-popover">Sort by</button>
<div class="popover dropdown" id="dd-state" popover="" role="menu">
<button class="dropdown-item" type="button" role="menuitem">Recently updated</button>
<button class="dropdown-item" type="button" role="menuitem" aria-current="true">Recently created</button>
<button class="dropdown-item" type="button" role="menuitem">Alphabetical</button>
<button class="dropdown-item" type="button" role="menuitem" disabled="">Custom (locked)</button>
</div>
</brio-dropdown>
</docs-resize>End-aligned
The default placement is below the trigger, aligned to its
inline-start edge. .is-end on the
.popover.dropdown panel switches to
inline-end alignment -- the menu's trailing edge meets the
trigger's trailing edge. Useful for triggers near the
inline-end edge of a layout, where left-aligned menus
would overflow. (The browser also flips automatically via
position-try-fallbacks; the variant lets you
express a preferred alignment up front.)
<docs-resize>
<div class="flow">
<div class="cluster is-between">
<span>Trigger near the inline-start edge:</span>
<brio-dropdown>
<button class="button is-outlined" type="button" commandfor="dd-start-align" command="toggle-popover">Open</button>
<div class="popover dropdown" id="dd-start-align" popover="" role="menu">
<button class="dropdown-item" type="button" role="menuitem">Default alignment</button>
<button class="dropdown-item" type="button" role="menuitem">Below, start-aligned</button>
</div>
</brio-dropdown>
</div>
<div class="cluster is-between">
<span>Trigger near the inline-end edge:</span>
<brio-dropdown>
<button class="button is-outlined" type="button" commandfor="dd-end-align" command="toggle-popover">Open</button>
<div class="popover dropdown is-end" id="dd-end-align" popover="" role="menu">
<button class="dropdown-item" type="button" role="menuitem">End-aligned</button>
<button class="dropdown-item" type="button" role="menuitem">Trailing edges meet</button>
</div>
</brio-dropdown>
</div>
</div>
</docs-resize>Checkmark indicator (customization)
The default aria-current="true" treatment is a
background tint plus primary-tinted text. For
selection menus where the active option needs an explicit
glyph as well -- a theme picker, a sort-by menu, a unit
selector -- add an opt-in .is-checked class
on the .popover.dropdown panel and a few
lines of CSS in your own stylesheet:
.popover.dropdown.is-checked .dropdown-item::before {
content: "";
inline-size: var(--icon-size);
flex-shrink: 0;
text-align: center;
}
.popover.dropdown.is-checked .dropdown-item[aria-current="true"]::before {
content: "\2713";
color: var(--color-primary);
}
The ::before on every item reserves a
leading slot at --icon-size width so
non-current items still align with the current one's
checkmark. This sits in the same inline-start position as
the icon slot (a first-child <svg> or
<img>), so menus that already use
icons should either skip the pattern or render their
icons inside the .dropdown-item body rather
than as the first child. brio's docs site uses this rule
-- the same one above, lifted into
docs.css -- on the theme picker in the page
header.
<docs-resize>
<brio-dropdown>
<button class="button" type="button" commandfor="dd-checked" command="toggle-popover">Sort by</button>
<div class="popover dropdown is-checked" id="dd-checked" popover="" role="menu">
<button class="dropdown-item" type="button" role="menuitem">Recently updated</button>
<button class="dropdown-item" type="button" role="menuitem" aria-current="true">Recently created</button>
<button class="dropdown-item" type="button" role="menuitem">Alphabetical</button>
</div>
</brio-dropdown>
</docs-resize>State and interaction
The Popover API drives open / close and light dismiss; the
<brio-dropdown> custom element adds the
behaviors a menu needs but the platform doesn't yet
provide:
- Arrow keys / Home / End: roving tabindex across enabled items, with circular wrap. Disabled items skip.
- Focus on open: when the menu opens, focus moves to the first enabled item.
- Focus return: when the menu closes (Escape, click outside, item activation), focus returns to the trigger.
- Close on item click: clicking a
menuitem dismisses the menu so the action's effect is
visible. The consumer's own click handler on the item
still fires first; default behavior like
<a href>navigation also runs -- the dropdown just closes alongside. - Trigger ARIA wiring:
aria-haspopup="menu"is set when the element connects;aria-expandedflips between"false"and"true"as the menu opens and closes. The Popover API doesn't manage these attributes -- brio-dropdown owns them. - Disclosure chevron: a small CSS-drawn
chevron renders after the trigger text on any
.buttonor.nav-linkthat carriesaria-haspopup="menu"-- which brio-dropdown sets automatically. The chevron rotates 180° when the menu opens, keying off the samearia-expandedstate. Opt out with.no-chevronon the trigger -- typical for icon-only triggers (notifications bell, avatar) where a chevron next to the icon adds noise without information. - Close on scroll: popovers live in the top layer and visually detach from triggers inside sticky / fixed ancestors when the page scrolls. The dropdown closes on any ancestor scroll, matching the convention from native menus on macOS and Windows.
Animation
Inherited from popover:
@starting-style +
transition-behavior: allow-discrete, with
opacity transitions fenced behind a non-Safari
@supports block. Same recipe, same
--duration-fast / reduced-motion handling.
In a navbar
A real app navbar mixes more than just dropdowns -- the patterns that show up alongside them in production are what stress-test cross-browser behavior. The demo below combines:
- Links next to dropdowns -- Pricing as
an
<a class="nav-link">sibling of the Products dropdown trigger, both styled to match. - A popover megamenu next to a dropdown -- Resources is a wide popover (not a menu) holding grouped link sections; sits beside Products which is a dropdown. Different keyboard models, different ARIA semantics, same visual neighborhood.
- A search form -- a real
<form role="search">with an input, sharing the trailing-edge cluster with the dropdowns. Tests focus interactions when an interactive non-menu sibling is present. - An icon-only dropdown trigger --
notifications bell, no visible text, requires
aria-labelon the trigger for the screen-reader name. - Mixed item types in one menu -- the
Account menu has
<button>items (current selection, sign out) alongside<a href>items (settings, billing) all carrying.dropdown-itemfor shared styling.
Open multiple, click outside, press Escape, arrow-key navigate, click items, scroll the page, resize the container. Each browser should behave identically.
One platform note: when testing the megamenu's link grid on
macOS, Tab will likely skip the <a>
items inside it -- macOS only includes form controls in
Tab order by default, unless the user has enabled full
keyboard navigation in their OS accessibility settings.
Windows and Linux include links in Tab order natively.
The links are focusable everywhere; the difference is
whether Tab visits them. Expected platform behavior, not
a brio bug.
<docs-resize>
<header class="navbar">
<div class="cluster is-between">
<a href="#"><strong>Acme</strong></a>
<nav class="cluster" aria-label="Primary">
<brio-dropdown>
<button class="nav-link" type="button" commandfor="nb-products" command="toggle-popover">Products</button>
<div class="popover dropdown" id="nb-products" popover="" role="menu">
<a class="dropdown-item" href="#" role="menuitem">Analytics</a>
<a class="dropdown-item" href="#" role="menuitem">Workflows</a>
<a class="dropdown-item" href="#" role="menuitem">Reports</a>
<hr role="separator"/>
<a class="dropdown-item" href="#" role="menuitem">All products</a>
</div>
</brio-dropdown>
<button class="nav-link" type="button" commandfor="nb-resources" command="toggle-popover">Resources</button>
<div class="popover is-lg" id="nb-resources" popover="">
<nav aria-label="Resources">
<div class="cluster">
<div class="stack">
<p class="dropdown-label">Learn</p>
<a class="nav-link" href="#">Tutorials</a>
<a class="nav-link" href="#">Guides</a>
<a class="nav-link" href="#">Examples</a>
</div>
<div class="stack">
<p class="dropdown-label">Build</p>
<a class="nav-link" href="#">CLI</a>
<a class="nav-link" href="#">SDKs</a>
<a class="nav-link" href="#">Templates</a>
</div>
<div class="stack">
<p class="dropdown-label">Connect</p>
<a class="nav-link" href="#">Community</a>
<a class="nav-link" href="#">Blog</a>
<a class="nav-link" href="#">Changelog</a>
</div>
</div>
</nav>
</div>
<a href="#" class="nav-link">Pricing</a>
</nav>
<div class="cluster">
<form class="cluster" role="search">
<input class="input" type="search" placeholder="Search..." aria-label="Search"/>
</form>
<brio-dropdown>
<button class="button is-ghost no-chevron" type="button" aria-label="Notifications" commandfor="nb-notifications" command="toggle-popover">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"></path>
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"></path>
</svg>
</button>
<div class="popover dropdown is-end" id="nb-notifications" popover="" role="menu">
<p class="dropdown-label">Recent</p>
<button class="dropdown-item" type="button" role="menuitem">Build #142 succeeded</button>
<button class="dropdown-item" type="button" role="menuitem">Sarah commented on PR #87</button>
<button class="dropdown-item" type="button" role="menuitem">3 new pull requests</button>
<hr role="separator"/>
<a class="dropdown-item" href="#" role="menuitem">View all notifications</a>
</div>
</brio-dropdown>
<brio-dropdown>
<button class="button" type="button" commandfor="nb-account" command="toggle-popover">Account</button>
<div class="popover dropdown is-end" id="nb-account" popover="" role="menu">
<p class="dropdown-label">Signed in as</p>
<button class="dropdown-item" type="button" role="menuitem" aria-current="true">patrick@octetic.com</button>
<hr role="separator"/>
<a class="dropdown-item" href="#" role="menuitem">Settings</a>
<a class="dropdown-item" href="#" role="menuitem">Billing</a>
<hr role="separator"/>
<button class="dropdown-item is-danger" type="button" role="menuitem">Sign out</button>
</div>
</brio-dropdown>
</div>
</div>
</header>
</docs-resize>Reference
.dropdown-- themed veneer applied alongside.popoveron the menu panel. Inherits the surface, positioning, and animation from popover; remaps padding for menu-tight chrome..dropdown-item-- a menu row. Pair withrole="menuitem". Intentional dual-pair: ARIA carries semantics, the class lets the same row styling apply in non-menu contexts.<svg>or<img>(first child of an item) -- leading icon slot. Slot keyed by element.<kbd>(direct child of an item) -- trailing keyboard hint, monospace. Slot keyed by element..dropdown-label-- uppercase group header inside the menu. Cross-context styling hook (works on<p>,<header>,<h6>depending on whether the surrounding role accepts a heading)..is-danger-- destructive item variant..is-end(on the panel) -- end-aligned placement instead of start-aligned..no-chevron(on the trigger) -- suppresses the auto-applied disclosure chevron. Use on icon-only triggers where a chevron next to the icon is noise.
Not classes: <brio-dropdown> is a
custom element; role="menu",
role="menuitem", role="separator",
aria-current, and aria-disabled
are ARIA attributes; commandfor and
command are the native HTML invoker
attributes.
Customization
Dropdown remaps a handful of popover tokens (padding,
max-width) and exposes its own scoped tokens for
minimum width, the divider border, item padding, and
item color (text + hover background).
.is-danger on an item remaps both
--dropdown-item-color and
--dropdown-item-bg-hover to the danger
palette so the row tints consistently across resting and
hover states.
Full token list and defaults:
src/css/dropdown.css.
Accessibility
-
Menu semantics are required. The
panel needs
role="menu"; items needrole="menuitem". Without these, screen readers announce the panel as a generic group -- users lose the "menu" landmark and the navigation cue. brio's CSS still styles the items via the.dropdown-itemclass either way, but accessibility depends on the roles being present. -
Disabled items: choose the right form.
For
<button role="menuitem">, use:disabled-- it's the canonical button state and is keyboard-skipped natively. For<a role="menuitem">, usearia-disabled="true"; links don't accept:disabled, and removinghrefwould lose the anchor semantic. Both forms style and behave the same in brio. -
Current selection vs. active item.
Use
aria-current="true"on items that represent the current state of a setting (the selected sort order, the active theme). Don't use it for the focused item -- focus is a transient keyboard state, not a persistent semantic. Screen readers announcearia-currentas "current". -
Trigger gets the right ARIA automatically.
<brio-dropdown>setsaria-haspopup="menu"and managesaria-expandedon the trigger. Don't author these in markup -- the custom element overwrites them on connect. -
Item content needs a name. An
icon-only menu item without text has no accessible
name. Add
aria-labelto the<button>if the item's intent isn't conveyed by visible text.
CSS Reference: Dropdown
Action menu inside a popover
Source: src/css/dropdown.css
Tokens
| Token | Default | Description |
|---|---|---|
--dropdown-min-width | 10rem | Minimum width of the menu panel |
--dropdown-padding | var(--ui-pad-square-tight) | Inner padding for the menu panel |
--dropdown-divider-border | var(--border-width-default) solid var(--color-border) | Border for separator rules between groups |
--popover-padding-x | var(--dropdown-padding) | Horizontal padding override for the popover surface |
--popover-padding-y | var(--dropdown-padding) | Vertical padding override for the popover surface |
--popover-max-width | none | Max-width override, uncapped for menus |
--dropdown-item-padding-x | var(--ui-pad-x-tight) | Horizontal padding for menu items |
--dropdown-item-padding-y | var(--ui-pad-y-compact) | Vertical padding for menu items |
--dropdown-item-color | var(--color-text) | Text color for menu items |
--dropdown-item-bg-hover | var(--color-fill-on-float) | Background color on item hover |
--dropdown-item-bg-current | var(--color-state-bg-on-float) | Background color for the current selection |
--dropdown-item-border-hover | var(--color-fill-on-float) | Focus outline color on item hover |
--dropdown-item-radius | var(--radius-md) | Border radius for menu items |
Slots
| Slot | Description |
|---|---|
.dropdown-item | Clickable menu row, also matched by [role="menuitem"] |
.dropdown-label | Group header inside the menu |
.dropdown :is(hr, [role="separator"]) | hr or any element with role="separator" acts as a menu separator |
Classes
| Class | Description |
|---|---|
.dropdown | component root |
.is-end | End-aligned placement for triggers near the inline-end edge |
.is-danger | Destructive action with danger coloring |