Skip to content

Menu

A button that opens a list of actions. The widest keyboard surface in the library: arrows, Home, End, type-to-select, and a Tab that closes the menu and carries on out of it.

Use something else if the options are values rather than actions. A menu performs things; a select chooses one. Screen reader users are told “menu”/“menu item”, and being told that about a form field is a small lie that costs them time.

The two panels above are different libraries rendering the same core. Every arrow key, the typeahead buffer, the dismissal rules and every ARIA attribute come from the same @caioalfonso/kanso-core code; only the rendering differs.

import { Menu } from '@caioalfonso/kanso-react/menu';
import '@caioalfonso/kanso-styles/tokens';
import '@caioalfonso/kanso-styles/base';
import '@caioalfonso/kanso-styles/menu';

The stylesheet is optional — but for this component it also carries the positioning, so an unstyled menu appears in the document flow rather than over the page. See Installation for package setup, the Vue entry points, and why tokens and base come along.

PartElementNotes
root<div>Carries data-kanso and data-scope=“menu”. Also the positioning anchor.
trigger<button>aria-haspopup=“menu” and aria-expanded.
positioner<div>Renders only while open. Carries data-placement.
content<div>role=“menu”, labelled by the trigger.
item<button>role=“menuitem”. Takes value and optional disabled.
separator<div>role=“separator”. Decoration with a name.
group / group-label<div>The group points at its label; the id is generated for you.

The list above comes from menuAnatomy, which the library exports: {menuAnatomy.join(', ')}.

All of these go on Menu.Root.

PropTypeDefaultNotes
openbooleanControlled. Present ⇒ you own the state.
defaultOpenbooleanfalseUncontrolled initial value.
onOpenChange(open: boolean) => voidFires in both modes. Vue emits openChange.
onSelect(value: string) => voidThe chosen item’s value. Vue emits select.
loopbooleantrueWhether the arrows wrap past the ends.
typeaheadbooleantrueWhether typing letters moves focus.
idstringautoLands on the root verbatim; other ids derive from it.

Menu.Item takes a required value: string and an optional disabled.

Vue supports v-model:open on Menu.Root.

KeyContextAction
Enter / Space / ArrowDownTriggerOpen, focus the first item
ArrowUpTriggerOpen, focus the last item
ArrowDown / ArrowUpOpenMove focus, wrapping when loop
Home / EndOpenFirst / last item
Enter / SpaceOpenActivate the item, close, focus the trigger
EscapeOpenClose, focus the trigger
TabOpenClose and let focus move on
any single characterOpenTypeahead

Two of those need no handler at all: the trigger and every item are real <button> elements, so Enter and Space already produce a click.

A dialog traps focus; a menu must not. Tab closes the menu, puts focus back on the trigger, and then lets the browser’s own handling carry on to whatever follows it — so the user ends up past the menu button, exactly where Tab from an ordinary button would have taken them.

That single line of the APG is what settles the whole design: the menu composes dismissal and roving focus, and nothing marks the page inert.

Type “se” to jump to “Settings”. The buffer resets about half a second after the last keystroke, matching is case-insensitive, and the search wraps.

Pressing the same letter repeatedly cycles through the items starting with it — but only when the buffer matches no prefix, so an item genuinely called “SSH keys” is still reachable by typing its name.

Typeahead lands on disabled items, exactly as the arrows do.

Any single printable character counts, not just letters and digits — item labels contain punctuation and accents too. Space is the exception: it activates the focused item.

Escape is consumed by the open menu, so a page-level Escape shortcut does not also fire while a menu is open. Unlike Dialog, there is no prop to opt out of that; a menu that ignored Escape would be a menu you cannot leave by keyboard.

disabled on Menu.Item sets aria-disabled, not the disabled attribute. The item stays focusable and stays in the ring, so a keyboard user can discover that it exists and is unavailable rather than finding a gap. Activating it does nothing.

<Menu.Item value="share" disabled>
Share (unavailable)
</Menu.Item>

The menu renders in the document flow, positioned by CSS against Menu.Root. There is no portal and no positioning engine — docs/03 §4 records why, and the short version is that a portalled menu inside a dialog is a sibling of the dialog’s content, which is enough for the dialog’s focus trap to pull focus out of the menu you are using.

Placement is measured once when the menu opens and published as data-placement on the positioner: it flips above the trigger when it would overflow the bottom of the viewport, and flips its alignment when it would overflow the inline end. Nothing listens for resize or scroll afterwards.

The trade-off is the honest one: an ancestor with overflow: hidden clips the menu. If that is your layout, keep the menu out of the clipped container for now.

Follows the APG menu button pattern.

  • Trigger: aria-haspopup="menu" and aria-expanded, and deliberately no aria-controls — the content is unmounted while closed, so that idref would point at nothing for most of the component’s life.
  • Content: role="menu" labelled by the trigger. Items are role="menuitem"; groups are role="group" pointing at a generated label id.
  • Focus moves to real elements rather than through aria-activedescendant, so the focus ring is the browser’s own.
  • Items are at least 44px tall (WCAG 2.2 SC 2.5.8), and a long menu scrolls internally so a focused item is never pushed off-screen (SC 2.4.11).
  • The focused item is styled from :focus, not :focus-visible — unusually for this library, and deliberately. Focus here is moved by script, and the browser’s heuristic would hide the ring exactly when a keyboard user is navigating a menu they opened with the mouse.
<Menu.Root onSelect={(value) => run(value)}>

onSelect fires with the item’s value, and the menu closes itself. There is no need to also handle onOpenChange for that case.

Supported, and tested in both frameworks: Escape closes the menu first and leaves the dialog open, because dismissable layers are stacked. A second Escape closes the dialog.

[data-scope='menu'] [data-part='content'] {
/* your menu */
}
[data-scope='menu'] [data-part='item'][data-disabled] {
/* your unavailable item */
}

If you write your own, keep two things from the shipped stylesheet: the root needs position: relative (it is the anchor), and the positioner’s data-placement is what the collision handling produces — reading it is the only way that measurement reaches the screen.