Skip to content

Dropdown

A generic action menu built on the WAI-ARIA APG menu button pattern, with real roving-tabindex keyboard focus.

Import

import { Dropdown } from "@hyzer-labs/ui"

Demo

Dropdown vs Nav

Use Dropdown when the entries are actions: an action menu or kebab menu that does something (edit, duplicate, delete). For navigation, meaning links a reader clicks through to another page or section, use Nav's built-in dropdown instead. Its menu items are real navigable links rather than action buttons.

Every item carries its own onselect. Click one, or focus it and press Enter/Space, to fire its action, close the menu, and return focus to the trigger.

Last action: –

let lastAction = $state("–");

const roundItems: DropdownEntry[] = [
	{ id: 'view', label: 'View scorecard', onselect: () => (lastAction = 'View scorecard') },
	{ id: 'edit', label: 'Edit round', onselect: () => (lastAction = 'Edit round') },
	{ id: 'share', label: 'Share round', onselect: () => (lastAction = 'Share round') }
];

<Dropdown label="Round actions" items={roundItems} />
<p>Last action: {lastAction}</p>

Props

NameTypeDefaultNote
itemsDropdownEntry[]Required. A DropdownEntry is either a DropdownItem or a DropdownSeparator, both below.
labelstringVisible trigger text (and the accessible name, unless triggerLabel overrides it).
triggerLabelstringAccessible-name override; required for an icon-only trigger (no label).
triggerPropsDropdownTriggerProps{}Trigger appearance pass-through. Resolves to variant: 'outline', intent: 'neutral'.
triggerIconSnippet— (⇒ IconChevronDown)Decorative; the labeled trigger renders it trailing, the icon-only trigger renders it alone.
align'start' | 'center' | 'end''start'
onselect(id: string, item: DropdownItem) => voidFires on every activation, after the item's own onselect.
disabledbooleanfalse
classstringMerged after the hz-dropdown class.

DropdownItem

NameTypeDefaultNote
idstringRequired. Stable identity — keys the item, its DOM id, and the onselect callback.
labelstringRequired.
disabledbooleanStays focusable — reachable by Arrow / Home / End / typeahead — but inert on activation.
dangerbooleanDestructive action — surfaces the data-danger styling hook.
iconSnippetDecorative leading glyph; the label owns the accessible name.
onselect() => voidPer-item action, fired before the component's onselect.

DropdownSeparator

NameTypeDefaultNote
separatortrueA non-interactive divider — discriminates it from an actionable item.

Theme hooks

What this component promises your CSS. The reference theme styles exactly these — from @layer hz-theme, so your unlayered rules win. See Styling Components for the how.

Root class: .hz-dropdown

Data attributes

HookValuesStyles
data-openpresent while openHides the closed menu and rotates the chevron.
data-side'bottom' | 'top'On .hz-dropdown-menu — the resolved side, bottom unless flipped for lack of room below. See Positioning for the caret recipe.
data-align'start' | 'center' | 'end'On .hz-dropdown-menu — which edge the menu hangs from (or centered), RTL-aware.
data-dangerpresent on danger itemsPer-item. The label text carries the meaning too — colour alone never does.
data-disabledpresent on disabled itemsPer-item. Items stay focusable (aria-disabled, not the native attribute), so the disabled state is still discoverable.

Part classes

HookValuesStyles
.hz-dropdown-triggeron a ButtonMerged onto the composed Button, so the trigger carries .hz-button and its full data-attr set as well.
.hz-dropdown-menuchild elementThe menu surface.
.hz-dropdown-itemchild elementOne item. There is no data-active — the active item is real DOM focus, so style :focus / :focus-visible.
.hz-dropdown-item-iconchild elementAn item’s icon slot.
.hz-dropdown-separatorchild elementA separator row.

Accessibility

The trigger is a real button (the library's own Button) with aria-haspopup=menu, aria-expanded, and aria-controls. It opens a role=menu popup of role=menuitem buttons, named by the trigger via aria-labelledby.

Unlike Combobox, which keeps DOM focus on its text input and tracks a virtually-focused option with aria-activedescendant, Dropdown moves real DOM focus into the menu. Opening it lands focus on the first menuitem (the last, on ArrowUp). A roving tabindex (0 on the focused item, -1 on the rest) keeps the menu a single tab stop, so the active item is styled with native :focus rather than a data-active hook.

Keyboard: ArrowDown/ArrowUp move focus and wrap, including over disabled items. Home/End jump to the first and last item. Typing a character cycles focus to the next item whose label starts with it. Enter/Space activate the focused item through its own native button click, with no separate handling. Escape closes the menu and returns focus to the trigger. Tab/Shift+Tab close the menu and let focus move on, since there is no focus trap.

A disabled item keeps aria-disabled=true rather than the native disabled attribute, so it stays focusable and screen-reader users can discover it. Activating it does nothing.

References: APG Menu Button pattern