Dropdowns show a contextual menu when a trigger is clicked. They are the universal menu pattern, used in the site header, bars, and page content. One component, same classes everywhere.
Trigger patterns
The trigger is the element that opens the dropdown. provides built-in styling, no additional classes needed.
Icon + text + chevron
The most common trigger. The rotates 180 degrees when the dropdown is open.
<div class="dropdown">
<button class="dropdown-trigger" type="button" aria-haspopup="true" aria-expanded="false">
<div class="svg-icn" data-icon="user"><!-- svg --></div>
<span>Account</span>
<div class="svg-icn dropdown-chevron" data-icon="chevron-down"><!-- svg --></div>
</button>
<div class="dropdown-menu" role="menu">
<!-- items -->
</div>
</div>
Icon-only trigger
For bars and compact contexts. No text, no chevron. The icon alone signals "click for options."
<button class="dropdown-trigger" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">
<div class="svg-icn" data-icon="more-horizontal"><!-- svg --></div>
</button>
Button trigger
For page content where the trigger should look like a standard button.
<button class="dropdown-trigger button" data-variant="outline" type="button" ...>
Options
<div class="svg-icn dropdown-chevron"><!-- chevron svg --></div>
</button>
When to use a chevron
| Scenario | Chevron? | Why |
|---|---|---|
| Selector-style trigger (choosing a value) | Yes | Signals "pick from a list" |
| Navigation dropdown (Account, Profile) | Yes | Shows the menu expands downward |
| Icon-only trigger (⋯ more, settings gear) | No | The icon itself implies a menu |
| Button trigger with label | Optional | Use when the button looks like a selector |
Placement
Menus open below the trigger, left-aligned. Set data-placement on the to ask for something else.
| Value | Opens |
|---|---|
bottom-start |
Below, left-aligned — the default |
bottom-end |
Below, right-aligned |
top-start |
Above, left-aligned |
top-end |
Above, right-aligned |
<div class="dropdown" data-placement="bottom-end">
Each of the four, spread across the row so the alignment is visible. The two top-* triggers open upward.
Placement is a request, not a guarantee. On open the script measures the menu against the viewport and flips whichever axis would overflow, writing the result to data-resolved-placement — which is what the CSS actually styles. A menu near the bottom of the screen opens upward on its own; one near the right edge aligns right. Each axis flips only when the opposite side genuinely has more room, so a menu taller than the viewport stays where you asked rather than flipping into an equally bad spot.
That means you no longer have to hand-place menus to avoid overflow. still works and is read as bottom-end, but new markup should use data-placement.
To watch it happen, narrow the window until the bottom-end trigger above sits within a menu-width of the left edge — it will start opening left-aligned instead. Or open the top-start menu and scroll until there is no longer room above; it drops below. The flip re-runs on resize and scroll, not only at open, so a menu that was well placed a moment ago corrects itself rather than hanging off the edge.
It resolves per axis, and only when the opposite side genuinely has more room. A menu taller than the viewport stays where you asked rather than trading one bad position for another — there is no placement that fits, so it keeps the one you chose.
Inside a bar
The bar re-points placement for you, so don't hand-set it there. A dropdown directly inside becomes a full-height cell whose panel hangs off the bar's bottom border — the panel's own top border lands on the same pixel row as the bar's hairline and paints over it, so the two read as one line with no extra rules. Collision resolution still applies; the anchor moves, the logic does not.
Nesting
A dropdown may contain another dropdown — the bar's overflow panel demotes whole groups, dropdowns included. The machinery is nesting-aware: opening a nested dropdown keeps its ancestor panel open, closing a panel closes anything nested inside it, and the open/placement selectors use child combinators so an outer cannot paint an inner menu. Inside the bar's overflow panel a nested menu renders statically, as a disclosure — see the bar doc's Overflow section.
Item patterns
Text-only item
The simplest item, just a label with no icon.
<button class="dropdown-item" role="menuitem" type="button">Admin</button>
Icon-left item
An icon before the text reinforces the action. Use for action verbs like Edit, Download, Delete.
<button class="dropdown-item" role="menuitem" type="button">
<div class="svg-icn" data-icon="settings"><!-- svg --></div>
<span>Settings</span>
</button>
Icon-right item (trailing content)
Use to push trailing content to the right edge. Ideal for keyboard shortcuts, badges, status indicators, or chevrons hinting at sub-menus.
<button class="dropdown-item" role="menuitem" type="button">
<span>Undo</span>
<span class="dropdown-item-end"><kbd>Ctrl+Z</kbd></span>
</button>
Icon on both sides
The two are independent, so an item can carry a leading icon and trailing content at once. No extra class is needed: the item is a flex row, and takes margin-left: auto, so whatever sits between the two is pushed apart.
<button class="dropdown-item" role="menuitem" type="button">
<div class="svg-icn" data-icon="download"><!-- svg --></div>
<span>Download</span>
<span class="dropdown-item-end"><kbd>Ctrl+S</kbd></span>
</button>
Keep it to one leading icon and one trailing element. A row with an icon, a label, a shortcut and a badge reads as a table, and the whole item is flattened into a single announced string by role="menu" — see Accessibility below.
Item with description
Use for supporting text below the label.
<button class="dropdown-item" role="menuitem" type="button">
<div>
<div>From template</div>
<div class="dropdown-desc">Choose from pre-built project templates</div>
</div>
</button>
Avatar item
For user menus showing the logged-in user's identity.
The identity row is not a menu item, and role="menu" may only own menu items, groups and separators. So the panel keeps the identity row and the divider, and role="menu" moves inward onto a wrapping only the real items.
Both circles are the avatar component — , sized with data-size rather than a one-off width. Both are aria-hidden: the name follows in the markup, so an announced stray "E" adds nothing.
The menu opens left-aligned here, which is the default. In a real header the trigger sits at the far right, and the menu should be anchored to match — put data-placement="bottom-end" on the , not on the menu. That is genuine intent rather than overflow avoidance, so it is one of the few places the attribute is worth reaching for.
<div class="dropdown-menu">
<div class="dropdown-header">
<span class="avatar" aria-hidden="true">E</span>
<div>
<div id="account-name">Erlen Masson</div>
<div class="dropdown-desc">erlen@bydefault.studio</div>
</div>
</div>
<div class="dropdown-divider" role="separator"></div>
<div class="dropdown-group" role="menu" aria-labelledby="account-name">
<button class="dropdown-item" role="menuitem" type="button">…</button>
</div>
</div>
Section labels
Use for non-interactive section headers. A label cannot be a direct child of role="menu" either, so wrap each section in a with role="group" and point aria-labelledby at the label. Screen readers then announce "Actions, group, 3 items" instead of dropping the heading entirely.
<div class="dropdown-group" role="group" aria-labelledby="grp-actions">
<div class="dropdown-label" id="grp-actions">Actions</div>
<button class="dropdown-item" role="menuitem" type="button">Rename</button>
<button class="dropdown-item" role="menuitem" type="button">Duplicate</button>
</div>
<div class="dropdown-divider" role="separator"></div>
<div class="dropdown-group" role="group" aria-labelledby="grp-danger">
<div class="dropdown-label" id="grp-danger">Danger zone</div>
<button class="dropdown-item dropdown-item--danger" role="menuitem" type="button">Delete project</button>
</div>
Disabled item
<button class="dropdown-item" role="menuitem" type="button" aria-disabled="true">
Save as...
</button>
Use aria-disabled="true", not the disabled attribute. A disabled button is removed from the arrow cycle, so a keyboard user never learns the option exists while a mouse user can see it sitting there. aria-disabled keeps the item reachable and announced as unavailable, and dropdown.js refuses the activation.
Checkable items
Add role="menuitemcheckbox" with aria-checked for options that toggle. Selecting one does not close the menu — a multi-select the user has to reopen per choice is unusable. Use role="menuitemradio" inside a role="group" for one-of-N instead; the script clears the siblings for you.
<button class="dropdown-item is-selected" role="menuitemcheckbox" type="button"
data-value="name" aria-checked="true">
<span class="dropdown-checkmark"><!-- check icon, aria-hidden --></span>
<span>Name</span>
</button>
The checkmark is what carries the state — a background tint alone would fail WCAG 1.4.1. Its space is reserved in both states, so toggling never reflows the row.
Reacting to a selection
Activating any item fires a bubbling dropdown-select on the .
document.querySelector('.dropdown').addEventListener('dropdown-select', (event) => {
const { value, item, checked } = event.detail;
// checked is true/false for checkable items, null for plain ones
});
When to use what
| Pattern | When to use | Example |
|---|---|---|
| Icon-left | Action verbs, the icon reinforces what the action does | Edit, Download, Delete, Settings |
| Icon-right () | Trailing metadata: keyboard shortcuts, badges, status | Undo Ctrl+Z, Status Active |
| Both together | An action that also carries metadata. One icon each side, no more | Download Ctrl+S |
| No icon | Simple value selection, the text is self-explanatory | Role names, client names, sizes |
| Avatar () | User identification, profile menus | Account dropdown |
| Description () | Items that need explanation | "From template. Choose from pre-built..." |
| Section label () | Grouping related items under a heading | "Switch Role", "Danger zone" |
| Divider () | Separating logical groups | Between actions and danger items |
JavaScript
Include assets/js/dropdown.js on any page. It auto-initialises all elements, no setup needed.
<script src="/assets/js/dropdown.js"></script>
Clicking a toggles on the parent . Clicking outside, moving focus away, or pressing Escape closes it.
The script also wires the ARIA relationships you would otherwise repeat on every instance: aria-controls on the trigger, aria-labelledby on the menu, and tabindex="-1" on each item while the menu is open. You author the roles; the plumbing is applied for you.
Keyboard interactions
This is the WAI-ARIA menu button pattern in full. role="menu" commits a component to all of it — arrow keys alone are not enough.
| Key | Action |
|---|---|
Enter / Space / ArrowDown |
On the trigger: opens and focuses the first item |
ArrowUp |
On the trigger: opens and focuses the last item |
ArrowDown / ArrowUp |
In the menu: moves focus, wrapping from last to first and back |
Home / End |
Jumps to the first or last item |
| Printable character | Typeahead — focuses the next item whose label starts with what you typed. Repeating one character cycles through the items starting with it |
Enter / Space |
On a plain item: activates it, closes the menu, returns focus to the trigger. On a checkable item: toggles it and stays open |
Escape |
Closes and returns focus to the trigger |
Tab / Shift+Tab |
Closes the menu and continues from the trigger |
Accessibility
- Trigger:
aria-haspopup="true"andaria-expanded. The script addsaria-controls - Icon-only triggers: add
aria-labeldescribing the action - Menu:
role="menu"— and it may own onlymenuitem,menuitemcheckbox,menuitemradio,groupandseparator. Any other content belongs in a outside it, or inside a - Items:
role="menuitem"on buttons. An<a>used as an item takesrole="menuitem"too, which replaces its link role — so reserve<a>items for genuine navigation and accept that they announce as menu items - Dividers:
role="separator" - Disabled items:
aria-disabled="true"only. Never thedisabledattribute, which drops the item out of the keyboard cycle - Checkable items:
role="menuitemcheckbox"witharia-checkedon the button itself. The checkmark icon must bearia-hiddenor it double-announces with the state - Because
role="menu"puts screen readers into application mode, rich item content (,<kbd>shortcuts) is flattened into the item's accessible name. Keep item content short and meaningful when read as one string
Structure reference
| Element | Class | Purpose |
|---|---|---|
| Container | Positioning context | |
| Trigger | Clickable element that opens the menu | |
| Chevron | Rotating arrow indicator (on trigger) | |
| Menu | The panel that appears | |
| Menu (right) | .dropdown-menu .is-right |
Right-aligned from trigger |
| Item | Clickable row | |
| Item (danger) | .dropdown-item .dropdown-item--danger |
Destructive action |
| Item (disabled) | .dropdown-item[aria-disabled="true"] |
Unavailable action |
| Item (checked) | .dropdown-item .is-selected |
Selected checkable item |
| Checkmark | Selection indicator, space always reserved | |
| Group | role="group" or nested role="menu" wrapper |
|
| Header | Non-interactive panel content, outside the menu role | |
| Trailing content | Right-aligned metadata inside an item | |
| Description | Supporting text under item label | |
| Label | Non-interactive section header, inside a group | |
| Divider | Separator line between groups |
Usage rules
Do:
- Use dropdowns for contextual actions and selections
- Group related items with labels and dividers
- Use icon-left for actions, icon-right for metadata
- Add
aria-labelon icon-only triggers - Leave placement alone unless you have a reason. The default opens below and left-aligned, and the script flips it if that would overflow
- Reach for
data-placementfor genuine intent — an account menu anchored to the right of a header — not to dodge an overflow the script already handles
Don't:
- Don't use dropdowns for primary navigation, use tabs or links
- Don't nest dropdowns inside dropdowns
- Don't use dropdowns for form field selection, use
<select>instead - Don't put more than 10 items in a single dropdown, break into sections or use a different pattern
CSS reference
This section documents how the component is built. For usage, see the sections above.
Tokens
| Token | Default | What it controls |
|---|---|---|
| Menu background | ||
| Menu and divider border | ||
| Item hover background |
Selectors
| Selector | Purpose |
|---|---|
Positioning context (position: relative) |
|
| Clickable trigger: flex layout, hover styles | |
.dropdown-trigger:hover |
Trigger hover state |
.dropdown-trigger .svg-icn |
Icon sizing inside trigger |
.dropdown.is-open > .dropdown-trigger |
Active trigger state when menu is open |
Rotating arrow indicator, transitions transform |
|
.dropdown.is-open > .dropdown-trigger .dropdown-chevron |
Chevron rotated 180deg when open (child-combined so an outer open never rotates a nested one) |
| Absolutely positioned menu panel, hidden by default | |
.dropdown-menu.is-right |
Legacy right-alignment, read as bottom-end |
.dropdown[data-resolved-placement$="-end"] > .dropdown-menu |
Right-aligned, after collision resolution |
.dropdown[data-resolved-placement^="top"] > .dropdown-menu |
Opens upward, after collision resolution |
.dropdown[data-resolved-placement$="-start"] > .dropdown-menu.is-right |
Flip overriding the legacy class |
.dropdown.is-open > .dropdown-menu |
Visible menu state (child-combined for the same nesting reason) |
| Clickable row: padding, hover background | |
.dropdown-item:hover |
Item hover, uses |
.dropdown-item:focus-visible |
Focus row tint. The ring itself comes from the global button rule |
.dropdown-item .svg-icn |
Icon sizing inside items |
.dropdown-item.is-disabled, .dropdown-item[aria-disabled="true"] |
Disabled item: reduced opacity, not-allowed cursor |
| Group wrapper, re-declares column layout | |
| Non-interactive panel row | |
Selection indicator, hidden with visibility so the row cannot reflow |
|
.dropdown-item[aria-checked="true"] .dropdown-checkmark |
Revealed checkmark |
| Destructive action, danger colour | |
.dropdown-item--danger:hover |
Danger item hover |
| Separator line between groups | |
| Non-interactive section header | |
| Supporting text below item label | |
| Right-aligned trailing content inside an item |
Use in another product
The design system installs once per product:
npm install github:bydefaultstudio/design-system-dist#semver:^4.7.0
This component's styles ship in design-system.css. Its behaviour ships as dist/js/dropdown.js — copy it into the product's served assets and include it once per page:
<script src="assets/js/dropdown.js" defer></script>
In React, render this contract through the packaged adapter instead of writing the markup by hand:
import { Dropdown } from '@bydefaultstudio/design-system/react';
The adapter renders the contract above and bridges this component's events to props — see React.