role disclosure
Dropdown draft v0.4.1
A button that opens a menu of related actions.
A dropdown gathers actions that relate to one purpose (share, export, more actions on a row) behind a single button, and shows them in a menu when the button is activated. Activating an action closes the menu. The trigger is the Button component, so it takes every emphasis, intent and size of the button. A dropdown offers actions: choosing a value is the Select component, and a button whose main action sits next to its menu is the Split button component.
Web implementation
Root .ds-dropdown on a <div>. Properties map to attributes:
packages/components/src/components/dropdown/dropdown.css
/*
* Dropdown — vanilla CSS reference implementation.
* Spec: packages/components/specs/components/dropdown.spec.json
* Tokens: packages/tokens/dist/css/components/dropdown.css (generated from packages/tokens/src/components/dropdown.tokens.json)
*
* Rules:
* - Only --dropdown-* custom properties may appear here. No raw values, no semantic tokens.
* - The trigger is a Button (.ds-button, styled by button.css); this file styles the menu only.
* - No script: the menu is a native popover opened by the trigger's popovertarget. It sits in
* the top layer and is placed under the trigger with CSS anchor positioning, the trigger being
* its implicit anchor; without anchor positioning the browser's default popover placement
* (centered in the viewport) applies.
* - Item intent is a data attribute; states are native pseudo-classes.
* - Every state also matches [data-state="<state>"] so the docs site can force it for review;
* [data-state="expanded"] on the root shows the menu in the flow, below the trigger.
* - Private properties (--_*) are the single place an option or a state writes to: each intent
* sets its ladder (--_<property>-<state>), and the state rules pick the rung.
*/
.ds-dropdown {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
vertical-align: middle;
}
/* ---- Menu ------------------------------------------------------------------ */
.ds-dropdown__menu {
box-sizing: border-box;
padding: var(--dropdown-menu-padding);
border: var(--dropdown-menu-border-width) solid var(--dropdown-menu-border-color);
border-radius: var(--dropdown-menu-border-radius);
background-color: var(--dropdown-menu-background);
color: inherit;
overflow: auto;
}
/* Open: under the trigger, aligned with its start edge, at least as wide; flips when it does not fit.
The trigger is the menu's implicit anchor (popovertarget): no anchor-name to keep unique. */
@supports (position-area: block-end) {
.ds-dropdown__menu:popover-open {
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
inset: auto;
margin: 0;
margin-block-start: var(--dropdown-menu-offset);
min-inline-size: anchor-size(inline);
}
}
/* Forced expanded state (review): the menu shows in the flow, below the trigger. */
.ds-dropdown:where([data-state="expanded"]) > .ds-dropdown__menu:not(:popover-open) {
display: block;
position: static;
margin: 0;
margin-block-start: var(--dropdown-menu-offset);
}
.ds-dropdown__group {
margin: 0;
padding: 0;
list-style: none;
}
.ds-dropdown__separator {
margin-block: var(--dropdown-separator-margin-block);
margin-inline: 0;
border: none;
border-block-start: var(--dropdown-separator-width) solid var(--dropdown-separator-color);
}
/* ---- Item ------------------------------------------------------------------ */
.ds-dropdown__item {
/* Defaults = intent "neutral", state "default". */
--_background: var(--_background-default);
--_color: var(--_color-default);
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--dropdown-item-gap);
inline-size: 100%;
min-height: var(--dropdown-item-min-height);
margin: 0;
padding-block: 0;
padding-inline: var(--dropdown-item-padding-inline);
border: none;
border-radius: var(--dropdown-item-border-radius);
background-color: var(--_background);
color: var(--_color);
font-family: var(--dropdown-item-label-typography-font-family);
font-size: var(--dropdown-item-label-typography-font-size);
font-weight: var(--dropdown-item-label-typography-font-weight);
line-height: var(--dropdown-item-label-typography-line-height);
letter-spacing: var(--dropdown-item-label-typography-letter-spacing);
text-align: start;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
user-select: none;
appearance: none;
-webkit-appearance: none;
transition:
background-color var(--dropdown-item-transition-duration) var(--dropdown-item-transition-easing),
color var(--dropdown-item-transition-duration) var(--dropdown-item-transition-easing);
}
.ds-dropdown__item-label {
flex: 1 1 auto;
}
.ds-dropdown__item-icon {
display: inline-block;
flex: none;
inline-size: var(--dropdown-item-icon-size);
block-size: var(--dropdown-item-icon-size);
overflow: hidden;
font-family: var(--dropdown-item-icon-font-family);
font-size: var(--dropdown-item-icon-size);
font-weight: var(--dropdown-item-icon-font-weight);
font-style: normal;
font-variation-settings:
"FILL" var(--dropdown-item-icon-fill),
"GRAD" var(--dropdown-item-icon-grade),
"opsz" var(--dropdown-item-icon-optical-size);
font-feature-settings: "liga";
line-height: var(--dropdown-item-icon-size);
letter-spacing: normal;
text-transform: none;
white-space: nowrap;
overflow-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
}
/* ---- Item intent: color ladders --------------------------------------------- */
/* Selectors are wrapped in :where() so every intent has the same specificity as .ds-dropdown__item. */
.ds-dropdown__item:where([data-intent="neutral"], :not([data-intent])) {
--_background-default: var(--dropdown-item-background-neutral-default);
--_color-default: var(--dropdown-item-label-color-neutral-default);
--_background-hover: var(--dropdown-item-background-neutral-hover);
--_color-hover: var(--dropdown-item-label-color-neutral-hover);
--_background-active: var(--dropdown-item-background-neutral-active);
--_color-active: var(--dropdown-item-label-color-neutral-active);
}
.ds-dropdown__item:where([data-intent="error"]) {
--_background-default: var(--dropdown-item-background-error-default);
--_color-default: var(--dropdown-item-label-color-error-default);
--_background-hover: var(--dropdown-item-background-error-hover);
--_color-hover: var(--dropdown-item-label-color-error-hover);
--_background-active: var(--dropdown-item-background-error-active);
--_color-active: var(--dropdown-item-label-color-error-active);
}
/* ---- Item states ------------------------------------------------------------ */
/* Order matters: default < hover < active < disabled. Guards keep hover/active off disabled items.
A disabled link item has no href and carries aria-disabled="true" instead of :disabled. */
.ds-dropdown__item:where(:hover, [data-state="hover"]):not(:disabled, [aria-disabled="true"], [data-state="disabled"]) {
--_background: var(--_background-hover);
--_color: var(--_color-hover);
}
.ds-dropdown__item:where(:active, [data-state="active"]):not(:disabled, [aria-disabled="true"], [data-state="disabled"]) {
--_background: var(--_background-active);
--_color: var(--_color-active);
}
.ds-dropdown__item:where(:disabled, [aria-disabled="true"], [data-state="disabled"]) {
--_background: var(--dropdown-item-background-disabled);
--_color: var(--dropdown-item-label-color-disabled);
cursor: not-allowed;
}
.ds-dropdown__item:focus {
outline: none;
}
.ds-dropdown__item:is(:focus-visible, [data-state="focus-visible"]) {
outline: var(--dropdown-item-outline-width-focus-visible) solid var(--dropdown-item-outline-color-focus-visible);
outline-offset: var(--dropdown-item-outline-offset-focus-visible);
}
@media (prefers-reduced-motion: reduce) {
.ds-dropdown__item {
transition: none;
}
}
packages/components/src/components/dropdown/dropdown.template.mjs
/**
* Dropdown — HTML template for the web implementation.
* Pure function of the spec properties (packages/components/specs/components/dropdown.spec.json) -> HTML string.
* Used by the docs site (server and client side). No DOM, no framework, no script needed:
* the trigger opens the menu with the native popover API (popovertarget), and every item closes
* it (popovertargetaction="hide"); the browser handles Escape, outside clicks and focus.
*
* The trigger is the Button component, rendered by its own template, with a chevron as its
* trailing icon. Items are native buttons, or links when they have an href.
*
* Extra props: `id` names the menu (popovertarget needs one, unique in the page; the trigger
* gets `<id>-trigger`, which names the menu); without it instances are numbered in render order.
* Extra sandbox-only props: `state` forces a state of the trigger (data-state on the Button;
* `expanded` shows the menu, data-state on the root), and an entry's `state` forces a state of
* that item.
*/
import { escapeHtml, escapeAttr, renderIcon, ICONS } from '../../lib/template-utils.mjs';
import { render as renderButton } from '../button/button.template.mjs';
export const meta = {
id: 'dropdown',
selector: '.ds-dropdown',
icons: ICONS,
};
let count = 0;
function renderItem(entry, menuId) {
const { label = 'Item', icon = null, intent = 'neutral', disabled = false, href = null, state = null } = entry;
const attrs = ['class="ds-dropdown__item"', 'data-part="item"', `data-intent="${escapeAttr(intent)}"`];
if (state && state !== 'default') attrs.push(`data-state="${escapeAttr(state)}"`);
const content = [
icon ? `<span class="ds-dropdown__item-icon" data-part="item-icon" aria-hidden="true">${renderIcon(icon)}</span>` : '',
`<span class="ds-dropdown__item-label" data-part="item-label">${escapeHtml(label)}</span>`,
].join('');
if (href) {
// A disabled link has no href (not focusable, not followed) and says it is disabled.
const link = disabled ? 'role="link" aria-disabled="true"' : `href="${escapeAttr(href)}"`;
return `<a ${attrs.join(' ')} ${link}>${content}</a>`;
}
attrs.unshift('type="button"');
attrs.push(`popovertarget="${escapeAttr(menuId)}"`, 'popovertargetaction="hide"');
if (disabled) attrs.push('disabled');
return `<button ${attrs.join(' ')}>${content}</button>`;
}
export function render(props = {}) {
const {
label = 'Actions',
emphasis = 'accent',
intent = 'neutral',
size = 'medium',
iconStart = null,
disabled = false,
items = [{ label: 'Item' }],
state = null,
id = null,
} = props;
const menuId = id ?? `ds-dropdown-${++count}`;
const expanded = state === 'expanded';
const trigger = renderButton({
emphasis,
intent,
size,
label,
iconStart,
iconEnd: 'expand_more',
disabled,
state: expanded ? null : state,
attrs: { class: 'ds-dropdown__trigger', 'data-part': 'trigger', id: `${menuId}-trigger`, popovertarget: menuId },
});
// Entries between separators form one group (a list); empty groups are dropped.
const groups = [[]];
for (const entry of items) {
if (entry.part === 'separator') groups.push([]);
else groups.at(-1).push(entry);
}
const menu = groups
.filter((g) => g.length)
.map((g) => `<ul class="ds-dropdown__group" data-part="group">${g.map((e) => `<li>${renderItem(e, menuId)}</li>`).join('')}</ul>`)
.join('<hr class="ds-dropdown__separator" data-part="separator">');
const rootAttrs = ['class="ds-dropdown"'];
if (expanded) rootAttrs.push('data-state="expanded"');
return `<div ${rootAttrs.join(' ')}>${trigger}<div class="ds-dropdown__menu" data-part="menu" id="${escapeAttr(menuId)}" popover aria-labelledby="${escapeAttr(menuId)}-trigger">${menu}</div></div>`;
}
No other platform implementation recorded yet. Platform token outputs live under Platforms.