Skip to content

Dropdown

A button that opens a menu. The surface is Popover — the same top layer, the same anchor positioning, the same flat bordered surface — and on top of it go role=menu, real DOM focus travelling row to row, and the keyboard the ARIA Authoring Practices describe, typeahead included. The rows are the library's shared menu row: a Dropdown row, a ComboBox option and a styled select picker row are the same object at every size.

  • Stable
  • React 19
  • APG menu keyboard
  • Built on Popover

A heading, three commands, a rule and one destructive row. Every menu on this page is drawn open, on the in-flow path.

Code
const ITEMS: DropdownItem[] = [
{ kind: 'group', label: 'Document' },
{ kind: 'item', label: 'Open', icon: <FolderIcon />, shortcut: '⌘O', onSelect: openDoc },
{ kind: 'item', label: 'Share', icon: <ShareIcon />, shortcut: '⇧⌘S', onSelect: share },
{ kind: 'item', label: 'Archive', shortcut: '⌘E', disabled: true, onSelect: archive },
{ kind: 'separator' },
{ kind: 'item', label: 'Delete', tone: 'danger', shortcut: '⌫', onSelect: remove },
];
<Dropdown items={ITEMS} trigger={<Button>Actions</Button>} />
<div style="padding-block-end: 12rem">
<div class="wayra-dropdown wayra-popover-anchor" data-color="primary" data-size="md">
<button type="button" id="usage-t" class="wayra-button" data-variant="filled" data-size="md" aria-haspopup="menu" aria-expanded="true" aria-controls="usage-m">Actions</button>
<div id="usage-m" role="menu" aria-labelledby="usage-t" data-open="true" class="wayra-dropdown-menu wayra-popover wayra-overlay">
<div role="presentation" class="wayra-dropdown-group">Document</div>
<button type="button" role="menuitem" tabindex="0" data-active="true" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg></span><span class="wayra-dropdown-label">Open</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⌘O</span></button>
<button type="button" role="menuitem" tabindex="-1" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11M12 4 8 8M12 4l4 4"/><path d="M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"/></svg></span><span class="wayra-dropdown-label">Share</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⇧⌘S</span></button>
<button type="button" role="menuitem" tabindex="-1" aria-disabled="true" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"></span><span class="wayra-dropdown-label">Archive</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⌘E</span></button>
<hr class="wayra-dropdown-separator">
<button type="button" role="menuitem" tabindex="-1" data-tone="danger" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"></span><span class="wayra-dropdown-label">Delete</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⌫</span></button>
</div>
</div>
</div>

Two required props and nothing else. items is data, not children: the rows are a discriminated union walked by the arrow keys and by typeahead, and index math over an array is what makes both of those a handful of pure functions instead of a tree walk. trigger is a single element — usually a Button — cloned with aria-haspopup, aria-expanded, aria-controls, an id and the toggle handler. Its own props and its own onClick survive the cloning.

The root is one box holding exactly two children, the trigger and the surface, and it wears wayra-popover-anchor as a co-class — it is the anchor container Popover requires.

Change the props and copy the code.

last selected: nothing yet

color
size
placement
align
width
Generated code
TSX
const ITEMS: DropdownItem[] = [
  { kind: 'group', label: 'Document' },
  { kind: 'item', label: 'Open', icon: <FolderIcon />, shortcut: '⌘O', onSelect: open },
  { kind: 'item', label: 'Share', icon: <ShareIcon />, shortcut: '⇧⌘S', onSelect: share },
  { kind: 'item', label: 'Archive', shortcut: '⌘E', disabled: true, onSelect: archive },
  { kind: 'separator' },
  { kind: 'item', label: 'Delete', tone: 'danger', shortcut: '⌫', onSelect: remove },
];

<Dropdown
  items={ITEMS}
  trigger={<Button>Actions</Button>}
/>
Code
const ITEMS: DropdownItem[] = [
{ kind: 'group', label: 'View' },
{ kind: 'checkbox', label: 'Sidebar', checked: sidebar, onCheckedChange: setSidebar },
{ kind: 'checkbox', label: 'Ruler', checked: ruler, onCheckedChange: setRuler },
{ kind: 'separator' },
{ kind: 'item', label: 'Reset', onSelect: reset },
];
<div style="padding-block-end: 12rem">
<div class="wayra-dropdown wayra-popover-anchor" data-color="primary" data-size="md">
<button type="button" id="kinds-t" class="wayra-button" data-variant="filled" data-size="md" aria-haspopup="menu" aria-expanded="true" aria-controls="kinds-m">View</button>
<div id="kinds-m" role="menu" aria-labelledby="kinds-t" data-open="true" class="wayra-dropdown-menu wayra-popover wayra-overlay">
<div role="presentation" class="wayra-dropdown-group">View</div>
<button type="button" role="menuitemcheckbox" aria-checked="true" tabindex="-1" data-checked="true" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"></span><span class="wayra-dropdown-label">Sidebar</span></button>
<button type="button" role="menuitemcheckbox" aria-checked="false" tabindex="-1" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"></span><span class="wayra-dropdown-label">Ruler</span></button>
<hr class="wayra-dropdown-separator">
<button type="button" role="menuitem" tabindex="0" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"></span><span class="wayra-dropdown-label">Reset</span></button>
</div>
</div>
</div>
kind Element What it does
item <button role="menuitem"> Runs onSelect, then closes the menu and hands focus back to the trigger.
checkbox <button role="menuitemcheckbox"> Reports the new state through onCheckedChange and leaves the menu open.
separator <hr> A rule between two runs. Two in a row collapse to one.
group <div role="presentation"> A heading over the rows that follow.

A checkable row keeps the menu open on purpose: a View menu lets several boxes be ticked in one visit, and closing after each one would make that four round trips. The state is the consumer’s — checked is required and there is no uncontrolled mode.

shown: Sidebar

Code
import { useState } from "react";
import type { DropdownItem } from "wayra-ui";
import { Button, Dropdown } from "wayra-ui";
const PANELS = ["Sidebar", "Ruler", "Grid"] as const;
export function CheckableDemo() {
const [shown, setShown] = useState<readonly string[]>(["Sidebar"]);
function toggle(panel: string, checked: boolean) {
setShown((current) =>
checked ? [...current, panel] : current.filter((one) => one !== panel),
);
}
// A checkbox row is controlled by the consumer, like every checkbox in the
// library: the component reports the new state and never keeps one.
const items: DropdownItem[] = [
{ kind: "group", label: "View" },
...PANELS.map((panel) => ({
kind: "checkbox" as const,
label: panel,
checked: shown.includes(panel),
onCheckedChange: (checked: boolean) => {
toggle(panel, checked);
},
})),
{ kind: "separator" },
{
kind: "item",
label: "Reset",
onSelect: () => {
setShown(["Sidebar"]);
},
},
];
return (
<div className="hu-demo-col">
<Dropdown
items={items}
trigger={<Button variant="outline">View</Button>}
/>
<p className="hu-readout">
shown: {shown.length === 0 ? "nothing" : shown.join(", ")}
</p>
</div>
);
}

The heading is role="presentation" rather than a real role="group": a role="menu" admits menu rows, groups and separators as children, and a bare heading is none of the three. A real group nests the rows inside it, which ComboBox already does with the APG grouped listbox, building each group from options just as flat as these items; Dropdown has not taken that step yet, so its rows stay direct children of the menu. The separator is an <hr> because that element already means separator, drawn edge to edge with a negative inline margin that is exactly the surface’s own padding.

Every row is a gutter, a label and a shortcut column, and the gutter is what keeps every label starting on the same x whether the row carries an icon, a check mark, or nothing.

  • label is a string, not a ReactNode. It is what typeahead matches and what the row truncates, and both need real text. A long label ends in an ellipsis and never wraps — a menu row truncates, it does not reflow.
  • icon and shortcut are decorative. Both slots are aria-hidden: the icon repeats the label, and the shortcut names a key the row already activates. Nothing in either is announced.
  • The gutter is 1em, not the check’s 0.7em. It holds a glyph as well, and the check keeps its shared size inside it — which is why a checkable row and an icon row line their labels up exactly.

tone: 'danger' is for the one destructive row a menu usually ends with. It does not paint a colour; it re-points the accent pair to the error role, so the row’s text, its highlight fill and that highlight’s text colour all move together — and error clears 4.5:1 on base-100 by the library’s two-token colour contract.

A disabled row is marked with aria-disabled and left in place, never removed: it is still announced, and what skips it is the arrow keys and typeahead. It is greyed, not faded — the muted colour measures 5.64:1, where the usual 0.4 disabled opacity over this surface would not.

The highlight is a solid fill sweeping the whole row, not a wash, and it follows the keyboard and the pointer through one data-active attribute. It is also why this component is a documented exception to the library’s single focus-ring rule: the row takes real DOM focus, and a 3.5px halo inside a menu would sit on top of the row above it. The fill keys on :focus-visible as well as on [data-active], so the indicator survives even if the two ever drift apart.

A press goes one step past the highlight while the pointer is down: the role mixed 88% toward base-content, the page’s text colour. Toward black a dark -content label would fall to 3.38:1 in three of the themes that promise AA; toward the text colour every one of them keeps at least 5.18:1. Under forced colors a pressed row is Highlight, like the active one.

Code
<Dropdown size="sm" items={ITEMS} trigger={<Button size="sm">sm</Button>} />
<Dropdown items={ITEMS} trigger={<Button>md</Button>} />
<Dropdown size="lg" items={ITEMS} trigger={<Button size="lg">lg</Button>} />
<div style="padding-block-end: 7rem">
<div class="wayra-dropdown wayra-popover-anchor" data-color="primary" data-size="sm">
<button type="button" id="sz-sm-t" class="wayra-button" data-variant="filled" data-size="sm" aria-haspopup="menu" aria-expanded="true" aria-controls="sz-sm-m">sm</button>
<div id="sz-sm-m" role="menu" aria-labelledby="sz-sm-t" data-open="true" class="wayra-dropdown-menu wayra-popover wayra-overlay">
<button type="button" role="menuitem" tabindex="0" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg></span><span class="wayra-dropdown-label">Open</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⌘O</span></button>
<button type="button" role="menuitem" tabindex="-1" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11M12 4 8 8M12 4l4 4"/><path d="M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"/></svg></span><span class="wayra-dropdown-label">Share</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⇧⌘S</span></button>
</div>
</div>
</div>
<div style="padding-block-end: 7rem">
<div class="wayra-dropdown wayra-popover-anchor" data-color="primary" data-size="md">
<button type="button" id="sz-md-t" class="wayra-button" data-variant="filled" data-size="md" aria-haspopup="menu" aria-expanded="true" aria-controls="sz-md-m">md</button>
<div id="sz-md-m" role="menu" aria-labelledby="sz-md-t" data-open="true" class="wayra-dropdown-menu wayra-popover wayra-overlay">
<button type="button" role="menuitem" tabindex="0" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg></span><span class="wayra-dropdown-label">Open</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⌘O</span></button>
<button type="button" role="menuitem" tabindex="-1" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11M12 4 8 8M12 4l4 4"/><path d="M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"/></svg></span><span class="wayra-dropdown-label">Share</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⇧⌘S</span></button>
</div>
</div>
</div>
<div style="padding-block-end: 7rem">
<div class="wayra-dropdown wayra-popover-anchor" data-color="primary" data-size="lg">
<button type="button" id="sz-lg-t" class="wayra-button" data-variant="filled" data-size="lg" aria-haspopup="menu" aria-expanded="true" aria-controls="sz-lg-m">lg</button>
<div id="sz-lg-m" role="menu" aria-labelledby="sz-lg-t" data-open="true" class="wayra-dropdown-menu wayra-popover wayra-overlay">
<button type="button" role="menuitem" tabindex="0" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg></span><span class="wayra-dropdown-label">Open</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⌘O</span></button>
<button type="button" role="menuitem" tabindex="-1" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11M12 4 8 8M12 4l4 4"/><path d="M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"/></svg></span><span class="wayra-dropdown-label">Share</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⇧⌘S</span></button>
</div>
</div>
</div>

Three steps, not the shared five: a menu row is text with two gutters, and xs and xl fall outside what a menu should ever be. Each step measures its rows against the same field ladder a Button and an Input of that step use, so a menu never looks like it came from a different set — and the menu’s own corner is concentric with the trigger’s, because the surface radius is the control’s radius plus the surface’s padding. Every row clears WCAG 2.5.8’s 24px target floor; at the small step the floor is what wins, and everywhere else the ladder is already above it.

placement, align and matchAnchorWidth pass straight through to Popover and behave exactly as documented there — with one changed default: matchAnchorWidth is true here. That is the library’s rule that a menu is as wide as the control it belongs to, and a menu is the case the rule was written for. Everything else about the box — the top layer, the anchor positioning, the flip when there is no room, the light dismiss (below the top-layer tier, Popover’s own close on a press outside the Dropdown root), the opaque bordered surface, the capped height that turns a long menu into a scrolling region — is Popover’s, unchanged. So is placement="cover", which lays the menu over the trigger itself; Fab’s flower is the layout it exists for.

<Dropdown open={open} onOpenChange={setOpen} items={ITEMS} trigger={<Button>Actions</Button>} />

open makes React the owner, defaultOpen seeds the uncontrolled state, and onOpenChange fires for every change including the ones the browser started on its own — light dismiss and its own Escape. Focus is where the two paths differ:

  • Opening from open or defaultOpen moves no focus. Nothing user-initiated happened, so nothing is stolen; the keyboard starts when the user tabs to a row.
  • Opening from the trigger does. A click lands on the first selectable row, ArrowDown on the first, ArrowUp on the last.
  • Closing on purpose returns focus to the trigger. Escape, Tab and selecting a row all do it. A close the browser started does not — there is nothing to return.

A closed menu still holds exactly one tab stop, on the row that would take focus first, which is what keeps a scrollable menu from being a focusable region with nothing focusable inside it.

DOM focus really moves onto the rows — a roving tabindex, the same mechanism Tabs uses — rather than travelling through aria-activedescendant: a menu row is a real <button>, and Enter has to reach it.

Key Behavior
Enter / Space on the trigger Opens the menu on the first selectable row.
↓ on the trigger Opens on the first selectable row.
↑ on the trigger Opens on the last selectable row.
↓ / ↑ in the menu Next or previous selectable row. Wraps at both ends; steps over separators, headings and disabled rows.
Home / End The first and the last selectable row.
Enter / Space on a row Activates it. An item runs and closes; a checkbox toggles and stays.
Printable characters Typeahead. See below.
Escape Closes the menu and puts focus back on the trigger.
Tab / Shift+Tab Closes the menu, puts focus back on the trigger, and does not call preventDefault — so the browser’s own Tab carries on from there to the next control.

Typeahead has four rules, all of them borrowed from a native menu:

  1. A 500ms buffer. A key inside the window lengthens the query; one after it starts a new one.
  2. One character cycles, more than one refines. A single letter starts the search at the row after the current one, so pressing it again walks through every row beginning with it; a longer query starts at the current row, so refining a query that already matched keeps its match.
  3. Accents fold. a finds Ánimo — the library’s default matching pipeline, not a special case here.
  4. A modifier makes it a command. ⌘O is Open, not the letter o, so a modified keystroke is left to the browser untouched.

The class plus the data-* attributes is the public API, and every menu on this page is written against it with no React involved:

<div class="wayra-dropdown wayra-popover-anchor" data-color="primary" data-size="md">
<button type="button" id="t" class="wayra-button" aria-haspopup="menu"
aria-expanded="true" aria-controls="m">Actions</button>
<div id="m" role="menu" aria-labelledby="t" data-open="true"
class="wayra-dropdown-menu wayra-popover wayra-overlay">
<div role="presentation" class="wayra-dropdown-group">Document</div>
<button type="button" role="menuitem" tabindex="0" data-active="true" class="wayra-dropdown-item">
<span aria-hidden="true" class="wayra-dropdown-lead"></span>
<span class="wayra-dropdown-label">Open</span>
<span aria-hidden="true" class="wayra-dropdown-shortcut">⌘O</span>
</button>
<hr class="wayra-dropdown-separator">
</div>
</div>

The component also writes data-open on the root while the menu is open, for consumer CSS; no library rule reads it. Each row carries --wayra-dropdown-index and the menu --wayra-dropdown-count, the numbers a layout that spreads the rows out computes from — Fab is the one that does.

What hand-written markup does not get is the keyboard: the roving tabindex, typeahead, Escape and the focus return are the component’s, and they are the whole reason it exists. Reproduce the paint by hand, never the behaviour.

role="menu" means a menu of commands — the application menu bar model, where arrow keys move and the whole widget is one tab stop. A list of links to other pages is not that: it is a <nav> around anchors, where every link is its own tab stop and a screen reader can list them. Dressing navigation as a menu takes both away.

submenu is a reserved name on the type, not a prop — declared never, so passing one fails typecheck rather than being silently dropped. A nested menu needs its own surface, its own open timing and a second keyboard axis; it is a later phase. The union has four kinds and menuitemradio is not one of them. Nor is there a compound API (Dropdown.Item and friends): the arrow keys, Home/End and typeahead are index math over an array, and children would mean walking a tree to answer the same question.

DropdownProps extends ComponentProps<"div"> minus color and children, so id, style, ref, className and every data-* land on the root box.

Prop Type Default Description
items readonly DropdownItem[] — required The rows, in order. A menu with no rows has nothing to open.
trigger ReactElement — required The button that opens the menu, as a single element. Cloned with the ARIA wiring and the toggle handler; its own props and handler survive.
color WayraColor "primary" Semantic role of the highlight that sweeps the active row. A danger row re-points it to error for itself.
size "sm" | "md" | "lg" "md" Size step of the rows. Three steps, not five.
placement "block-end" | "block-start" | "cover" "block-end" Which side of the trigger the menu opens on, or cover to lay it over the trigger. Passed to Popover.
align "start" | "center" | "end" "start" Which edge it lines up with, or center. Passed to Popover.
matchAnchorWidth boolean true Grow the menu to at least the trigger’s width. On by default here, unlike the bare Popover.
open boolean — Controlled. Opening from here moves no focus.
defaultOpen boolean false Uncontrolled first render. Moves no focus either.
onOpenChange (open: boolean) => void — Every change, including the closes the browser started.
unstyled boolean false Drops every class the component writes — the root’s pair, the menu’s three and each row part — and keeps the roles, the attributes and the whole keyboard.

DropdownItem is DropdownAction | DropdownCheckbox | DropdownSeparator | DropdownGroup, discriminated on kind. The first two share DropdownRowBase:

Prop Type Default Description
label string — required The row’s text. A string, because typeahead matches it and the row truncates it.
icon ReactNode — Leading glyph, in the shared gutter. Decorative; the slot is aria-hidden.
shortcut ReactNode — Trailing hint, muted on the far edge. Decorative as well.
disabled boolean false aria-disabled rather than removed: still announced, skipped by the arrows and by typeahead.

DropdownAction — kind: "item", a row that does something and closes.

Prop Type Default Description
onSelect () => void — required Runs on click, on Enter and on Space. The menu closes after it.
tone "danger" — Paints the row and its highlight in the error role.
submenu never — Reserved name, not a prop.

DropdownCheckbox — kind: "checkbox", a row that toggles and stays.

Prop Type Default Description
checked boolean — required Controlled: the consumer owns it.
onCheckedChange (checked: boolean) => void — required Fires with the new state. The menu stays open and the row keeps focus.

DropdownGroup — kind: "group" plus a label: string. DropdownSeparator — kind: "separator" and nothing else.

  • The trigger says what it opens. aria-haspopup="menu", aria-expanded tracking the state, and aria-controls pointing at the surface. The menu names itself back with aria-labelledby, so the trigger’s own text is the menu’s accessible name — no second string to translate.
  • Rows are menuitem or menuitemcheckbox, with aria-checked on the checkable ones. A disabled row keeps aria-disabled="true" and stays in the tree, so a screen reader user hears that the command exists and is unavailable rather than never hearing about it.
  • Focus is real, not simulated. One row holds tabindex="0" and the rest -1; the active row is focused for real, so the highlight is the focus indicator instead of a painted echo of one. A long menu scrolls, and the region it makes always contains that single tab stop.
  • Escape is consumed here, at the innermost open surface, and returns focus to the trigger. Tab also closes and returns focus but lets the default action run, so focus lands on the next control instead of on the body.
  • Reduced motion turns the panel’s entrance into a 160ms fade in place and makes the row highlight instant, through variables, never properties.
  • Windows high contrast gives the active row Highlight / HighlightText with forced-color-adjust: none, a disabled row GrayText, and repaints the separator from CanvasText. The check mark keeps its row’s system colour through forced-color-adjust: preserve-parent-color, so a disabled row’s check is GrayText too.

Do

Commands. Verbs the menu runs, one tab stop for the whole widget, arrow keys inside it.

Don't

Site navigation wearing menu semantics. Every link stops being its own tab stop, and a screen reader can no longer list the page's links.

Do

The unavailable command stays, greyed and aria-disabled. The menu keeps its shape and the row is still announced.

Don't

The row filtered out instead. The menu changes shape between visits, and nothing tells the user the command exists at all.