Stems UI
Menu

Toolbar

A container for grouping a set of buttons and controls.

Formatting: bold · Align: left · Inserted: none

Codeapp/demos/public/toolbar.tsx
import { clientEntry, type Handle } from 'remix/component'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
  onMenuSelect,
} from '@stemsui/registry/ui/dropdown-menu.tsx'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@stemsui/registry/ui/select.tsx'
import {
  ToggleButtonGroup,
  ToggleButtonGroupItem,
} from '@stemsui/registry/ui/toggle-button-group.tsx'
import { toggleButtonVariants } from '@stemsui/registry/ui/toggle-button.tsx'
import {
  Toolbar,
  ToolbarButton,
  ToolbarLink,
  ToolbarSeparator,
  toolbarButton,
  toolbarItem,
} from '@stemsui/registry/ui/toolbar.tsx'

function Icon(handle: Handle<{ paths: string[] }>) {
  return () => (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
      aria-hidden="true"
    >
      {handle.props.paths.map((d) => (
        <path d={d} />
      ))}
    </svg>
  )
}

const bold = ['M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8']
const italic = ['M19 4h-9', 'M14 20H5', 'M15 4 9 20']
const underline = ['M6 4v6a6 6 0 0 0 12 0V4', 'M4 20h16']
const alignLeft = ['M21 5H3', 'M15 12H3', 'M17 19H3']
const alignCenter = ['M21 5H3', 'M17 12H7', 'M19 19H5']
const alignRight = ['M21 5H3', 'M21 12H9', 'M21 19H7']
const undo = ['M9 14 4 9l5-5', 'M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11']
const chevronDown = ['m6 9 6 6 6-6']

const fonts = [
  { value: 'sans', label: 'Sans serif' },
  { value: 'serif', label: 'Serif' },
  { value: 'mono', label: 'Monospace' },
]

export const ToolbarDemo = clientEntry(import.meta.url, function ToolbarDemo(handle: Handle) {
  let formatting: string[] = ['bold']
  let alignment: string[] = ['left']
  let lastAction = 'none'

  return () => (
    <div
      class="flex w-full flex-col items-center gap-4"
      mix={onMenuSelect((event) => {
        lastAction = String(event.value ?? event.label)
        void handle.update()
      })}
    >
      <Toolbar aria-label="Text formatting" class="w-full max-w-2xl">
        <ToolbarButton aria-label="Undo" disabled>
          <Icon paths={undo} />
        </ToolbarButton>
        <ToolbarSeparator />
        <ToggleButtonGroup
          aria-label="Style"
          multiple
          size="sm"
          spacing={1}
          value={formatting}
          onValueChange={(value) => {
            formatting = value
            void handle.update()
          }}
        >
          <ToggleButtonGroupItem value="bold" aria-label="Bold" mix={toolbarItem()}>
            <Icon paths={bold} />
          </ToggleButtonGroupItem>
          <ToggleButtonGroupItem value="italic" aria-label="Italic" mix={toolbarItem()}>
            <Icon paths={italic} />
          </ToggleButtonGroupItem>
          <ToggleButtonGroupItem value="underline" aria-label="Underline" mix={toolbarItem()}>
            <Icon paths={underline} />
          </ToggleButtonGroupItem>
        </ToggleButtonGroup>
        <ToolbarSeparator />
        <ToggleButtonGroup
          aria-label="Alignment"
          size="sm"
          spacing={1}
          value={alignment}
          onValueChange={(value) => {
            // Keep one alignment pressed.
            if (value.length > 0) alignment = value
            void handle.update()
          }}
        >
          <ToggleButtonGroupItem value="left" aria-label="Align left" mix={toolbarItem()}>
            <Icon paths={alignLeft} />
          </ToggleButtonGroupItem>
          <ToggleButtonGroupItem value="center" aria-label="Align center" mix={toolbarItem()}>
            <Icon paths={alignCenter} />
          </ToggleButtonGroupItem>
          <ToggleButtonGroupItem value="right" aria-label="Align right" mix={toolbarItem()}>
            <Icon paths={alignRight} />
          </ToggleButtonGroupItem>
        </ToggleButtonGroup>
        <ToolbarSeparator />
        <Select items={fonts} defaultValue="sans">
          <SelectTrigger
            size="sm"
            aria-label="Font"
            class="w-32 border-transparent shadow-none hover:bg-muted dark:bg-transparent"
            mix={toolbarItem()}
          >
            <SelectValue />
          </SelectTrigger>
          <SelectContent alignItemWithTrigger={false} align="start">
            {fonts.map((font) => (
              <SelectItem value={font.value}>{font.label}</SelectItem>
            ))}
          </SelectContent>
        </Select>
        <DropdownMenu>
          <DropdownMenuTrigger
            class={toggleButtonVariants({ size: 'sm', class: 'gap-1 px-2' })}
            mix={toolbarButton()}
          >
            Insert
            <Icon paths={chevronDown} />
          </DropdownMenuTrigger>
          <DropdownMenuContent class="w-40">
            <DropdownMenuItem value="image">Image</DropdownMenuItem>
            <DropdownMenuItem value="table">Table</DropdownMenuItem>
            <DropdownMenuSeparator />
            <DropdownMenuItem value="divider">Divider</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
        <ToolbarSeparator class="ml-auto" />
        <ToolbarLink href="#history">Edited 5m ago</ToolbarLink>
      </Toolbar>
      <p class="text-sm text-muted-foreground">
        Formatting: {formatting.length > 0 ? formatting.join(', ') : 'none'} · Align: {alignment[0]}{' '}
        · Inserted: {lastAction}
      </p>
    </div>
  )
})

Installation

npx shadcn add @stemsui/toolbar

Requires the one-time setup. Behavior: stems-ui/toolbar, stems-ui/toggle-button-group, stems-ui/select, stems-ui/menu.

API reference

The styled component is built on the headless modules below: its props mirror these options, and the same events and data attributes apply.

stems-ui/toolbar

toolbar groups buttons, links, inputs, and other controls (toggle button groups, menus, selects) into one role="toolbar" widget with a single roving tab stop and arrow-key navigation. App code owns the markup and visual styles.

Base UI equivalent: Toolbar.

Usage

import * as menu from 'stems-ui/menu'
import { toggleButton } from 'stems-ui/toggle-button'
import * as toggleButtonGroup from 'stems-ui/toggle-button-group'
import * as toolbar from 'stems-ui/toolbar'

function Formatting() {
  return () => (
    <toolbar.Context>
      <div aria-label="Formatting" mix={toolbar.root()}>
        <toggleButtonGroup.Context multiple>
          <div aria-label="Style" mix={toggleButtonGroup.root()}>
            <button mix={[toolbar.button(), toggleButton({ value: 'bold' })]}>B</button>
            <button mix={[toolbar.button(), toggleButton({ value: 'italic' })]}>I</button>
          </div>
        </toggleButtonGroup.Context>
        <div mix={toolbar.separator()} />
        <menu.Context>
          <button mix={[toolbar.button(), menu.trigger()]}>Insert</button>
          <div mix={menu.popup()}>
            <div mix={menu.item()}>Image</div>
          </div>
        </menu.Context>
        <toolbar.GroupContext disabled>
          <div aria-label="History" mix={toolbar.group()}>
            <button mix={toolbar.button()}>Undo</button>
          </div>
        </toolbar.GroupContext>
        <input aria-label="Find" mix={toolbar.input()} />
        <a href="/history" mix={toolbar.link()}>
          Edited 5m ago
        </a>
      </div>
    </toolbar.Context>
  )
}
Putting other widgets in a toolbar

Base UI does this with <Toolbar.Button render={<Menu.Trigger />} />. In Stems, add a toolbar mixin to the other widget's element, first in mix (mixin attributes are defaults, and the first one wins, so the toolbar's tabIndex takes precedence):

  • toolbar.button(options) for <button> elements: toggle buttons, and the triggers of menus, selects, popovers, dialogs. It adds the stems-ui/button behavior, so disabled set here keeps the element focusable (aria-disabled) and blocks its activation, as in Base UI: set disabled on the toolbar mixin rather than on the trigger.
  • toolbar.item(options) for anything else (a non-button host, a number field input, a custom widget). It only adds the element to the roving focus; disable the composed widget itself too.

A toggleButtonGroup.Context inside a toolbar hands its roving focus to the toolbar, so its buttons need a toolbar mixin like any other item.

stems-ui/toolbar

  • Context: owns the toolbar state. Props (Base UI Toolbar.Root):
    • orientation: 'horizontal' (default; Left/Right, mirrored in RTL) or 'vertical' (Up/Down).
    • loopFocus: wrap arrow-key focus at the ends. Defaults to true.
    • disabled: disable every item. Items stay focusable unless focusableWhenDisabled: false.
    • handle.context.get(toolbar.Context) returns { orientation, loopFocus, disabled }.
  • GroupContext: disabled for the items inside a group. Base UI's Toolbar.Group takes disabled itself; a mixin can't pass state to the elements inside it, so the prop lives on this provider (wrap the group() element in it).
  • root(): role="toolbar", aria-orientation, data-orientation, data-disabled, and the keyboard handling. Name it with aria-label or aria-labelledby.
  • button(options): a toolbar item with button behavior (type="button", or role="button" and Enter/Space on other hosts). Options:
    • disabled: also set by the toolbar or the group.
    • focusableWhenDisabled: default true. A disabled button gets aria-disabled and stays in the arrow-key order; with false it gets the native disabled attribute and is skipped.
    • nativeButton: whether the host is a <button> (default: detected from the host).
  • link(): a toolbar item for an <a href>. Links can't be disabled.
  • input(options): a toolbar item for a native <input>. disabled, focusableWhenDisabled (default true: aria-disabled + readOnly, so the input stays focusable but can't be edited; false: native disabled). Clicks and pointer presses are prevented while disabled.
  • item(options): adds any element to the roving focus. disabled (affects navigation only; the toolbar's and group's disabled apply) and focusableWhenDisabled (default true).
  • group(): role="group", data-orientation, data-disabled.
  • separator(options): stems-ui/separator, with orientation defaulting to the opposite of the toolbar's (a horizontal toolbar gets vertical separators). Not focusable.

Every mixin can be called without arguments.

Behavior notes

  • Focus: the items form one tab stop: the last focused item, or the first focusable one. Disabled items stay in the order unless focusableWhenDisabled is false; natively disabled elements (:disabled, e.g. a disabled toggle button composed with toolbar.item()) are always skipped.
  • The keys are handled on root() in the bubble phase, only for key presses on an item that no other handler already used (event.defaultPrevented). A menu or select trigger keeps its own keys (ArrowDown opens it in a horizontal toolbar), and keys inside an open popup are ignored.
  • Inputs: Left / Right (Up / Down when vertical) move the caret and only leave the input from its start or end; Shift (selecting), an existing selection, and Home / End keep the native behavior. An input reached with the arrow keys has its text selected. Base UI recommends at most one input, placed last, in a horizontal toolbar.
  • Home / End move to the first / last item. Base UI's toolbar doesn't handle them; Stems follows the WAI-ARIA toolbar pattern here.
  • Base UI's render prop has no Stems equivalent; compose mixins instead (see above).
KeyAction
ArrowRight / ArrowLeftNext / previous item (horizontal; mirrored in RTL)
ArrowDown / ArrowUpNext / previous item (vertical)
Home / EndFirst / last item (not inside an input)
TabLeaves the toolbar

Styling hooks

PartAttributePresent when / value
rootdata-orientation"horizontal" or "vertical"
rootdata-disabledThe toolbar is disabled
buttondata-orientationThe toolbar's orientation
buttondata-disabledThe button, its group, or the toolbar is disabled
buttondata-focusablefocusableWhenDisabled is true (the default)
inputdata-orientationThe toolbar's orientation
inputdata-disabledThe input, its group, or the toolbar is disabled
inputdata-focusablefocusableWhenDisabled is true (the default)
linkdata-orientationThe toolbar's orientation
itemdata-orientationThe toolbar's orientation
groupdata-orientationThe toolbar's orientation
groupdata-disabledThe group or the toolbar is disabled
separatordata-orientation"vertical" in a horizontal toolbar, and vice versa

stems-ui/toggle-button-group

toggleButtonGroup shares a pressed-values array between toggle buttons from stems-ui/toggle-button, in single or multiple mode, with roving focus. App code owns the markup and visual styles.

Base UI equivalent: Toggle Group. It is named toggle-button-group because Remix's toggle is a switch.

Usage

import { toggleButton } from 'stems-ui/toggle-button'
import * as toggleButtonGroup from 'stems-ui/toggle-button-group'

function Alignment() {
  return () => (
    <toggleButtonGroup.Context defaultValue={['left']}>
      <div aria-label="Text alignment" mix={toggleButtonGroup.root()}>
        <button aria-label="Align left" mix={toggleButton({ value: 'left' })}>
          L
        </button>
        <button aria-label="Align center" mix={toggleButton({ value: 'center' })}>
          C
        </button>
        <button aria-label="Align right" mix={toggleButton({ value: 'right' })}>
          R
        </button>
      </div>
    </toggleButtonGroup.Context>
  )
}

Several pressed at once: <toggleButtonGroup.Context multiple>.

stems-ui/toggle-button-group

  • Context: owns the pressed values. Props (same as Base UI's ToggleGroup):
    • value: pressed button values. Controlled when not undefined.
    • defaultValue: initially pressed values (uncontrolled). Defaults to [].
    • onValueChange(value, event): called with the next value and the DOM event that caused it.
    • multiple: allow several pressed buttons. Defaults to false: pressing a button releases the others, and pressing the pressed one releases it (value []).
    • disabled: disable every button.
    • orientation: 'horizontal' (default; Left/Right, mirrored in RTL) or 'vertical' (Up/Down).
    • loopFocus: wrap arrow-key focus at the ends. Defaults to true.
    • handle.context.get(toggleButtonGroup.Context) returns { value, multiple, disabled, orientation, loopFocus }.
  • root(): sets role="group", data-orientation, data-disabled, and data-multiple. Name it with aria-label or aria-labelledby.
  • ToggleButtonGroupChangeEvent: bubbling stems:toggle-button-group-change event with the next value, dispatched from the button that changed. The button also dispatches its own stems:toggle-button-change.
  • onToggleButtonGroupChange(handler, capture?): typed listener mixin for the event.

Behavior notes

  • toggleButton({ value }) inside the Context reads and updates the group: its own pressed / defaultPressed are ignored, onPressedChange still fires, and the group's disabled applies. Give every button a value; a button without one gets a generated id as its value.
  • Focus: the buttons form one tab stop: the last focused button, or the first enabled one. Arrow keys (and Home/End) move focus without pressing; Space and Enter press. Disabled buttons are skipped.
  • Toggle buttons don't submit form values. Like Base UI's ToggleGroup, the group is not a Field control, but an enclosing <fieldset.Context disabled> disables it.
  • Base UI's change reason is always 'none', so Stems doesn't pass one.
KeyAction
ArrowRight / ArrowLeftNext / previous button (horizontal; mirrored in RTL)
ArrowDown / ArrowUpNext / previous button (vertical)
Home / EndFirst / last enabled button
Space / EnterPress or release the focused button

Styling hooks

PartAttributePresent when / value
rootdata-orientation"horizontal" or "vertical"
rootdata-disableddisabled is set
rootdata-multiplemultiple is set
buttondata-pressedThe button is pressed
buttondata-disabledThe button or the group is disabled

Inside a toolbar

When a toggle button group is placed inside a toolbar.Context, it hands arrow-key navigation to the toolbar so the whole toolbar has one tab stop. Add toolbar.item() (or toolbar.button()) to each toggle button's mix so it joins the toolbar's roving focus.

stems-ui/select

A button that opens a list of options to pick one value, or several with multiple. Equivalent of Base UI's Select. Built on lib/popup.ts (open state, top-layer popup, dismissal, positioning) and lib/listbox.ts (value, options, highlight, keyboard, typeahead).

Usage

import * as select from 'stems-ui/select'

let fruits = [
  { value: 'apple', label: 'Apple' },
  { value: 'banana', label: 'Banana' },
  { value: 'cherry', label: 'Cherry' },
]

function FruitSelect() {
  return () => (
    <form>
      <select.Context name="fruit" required items={fruits} defaultValue="banana">
        <div mix={select.label()}>Fruit</div>
        <button mix={select.trigger()}>
          <span mix={select.value()}>
            <select.ValueText placeholder="Pick a fruit" />
          </span>
          <span mix={select.icon()}>▾</span>
        </button>
        <div mix={select.popup()}>
          <div mix={select.scrollUpArrow()}>▴</div>
          <div mix={select.list()}>
            <div mix={select.group()}>
              <div mix={select.groupLabel()}>Fruits</div>
              {fruits.map((fruit) => (
                <select.ItemContext value={fruit.value}>
                  <div mix={select.item()}>
                    <span mix={select.itemText()}>{fruit.label}</span>
                    <span mix={select.itemIndicator()}>✓</span>
                  </div>
                </select.ItemContext>
              ))}
            </div>
          </div>
          <div mix={select.scrollDownArrow()}>▾</div>
        </div>
      </select.Context>
    </form>
  )
}

Multiple selection: the value is an array, the popup stays open while items are toggled, and ValueText joins the labels with ", " (or pass a render function):

<select.Context multiple name="languages" defaultValue={['js', 'ts']}>
  …
  <span mix={select.value()}>
    <select.ValueText>{(value: string[]) => `${value.length} selected`}</select.ValueText>
  </span>
  …<div mix={select.popup({ alignItemWithTrigger: false })}>…</div>
</select.Context>

Listen for changes with the callback or the bubbling event:

<select.Context onValueChange={(value, details) => console.log(value, details.reason)}>…

<div mix={select.onSelectChange((event) => console.log(event.value, event.reason))}>…</div>

API

Context (Base UI Select.Root)

Owns the state and renders the form input(s) after its children (Base UI renders its hidden input in Root too).

PropDefaultNotes
value / defaultValuenullA value or null; an array with multiple. Values may be any type (compared with isItemEqualToValue).
onValueChange(value, details)details: reason ('item-press' | 'list-navigation' | 'none'), event, cancel(), isCanceled.
multiplefalse
open / defaultOpenfalse
onOpenChange(open, details)Base UI reasons: trigger-press, outside-press, escape-key, item-press, focus-out, list-navigation, none. cancel().
onOpenChangeComplete(open)After the enter / exit animation.
modaltrueLocks scroll, makes outside content inert, traps focus. 'trap-focus' or false to relax.
name, form, autoCompleteForm participation.
requiredfalseNative constraint validation; an invalid submit focuses the trigger.
disabled, readOnlyfalsereadOnly keeps the popup closed and ignores typeahead.
items{ value: label }, [{ value, label }], or groups [{ items: [...] }]. Used for the value label, including in server HTML.
itemToStringLabel / itemToStringValueLabel for display / string for the form. Objects with label / value keys work without them.
isItemEqualToValueObject.is
highlightItemOnHovertrue
loopFocusfalseStems extra: wrap keyboard navigation.
onItemHighlighted(value, details)Stems extra.

handle.context.get(select.Context) returns SelectContextValue: open, value, selectedValues, hasValue, labelOf(value), stringValue(value), setValue(), setOpen(), plus the underlying store (popup) and listbox.

Parts
PartElementOptions
label()anyLabels the trigger and list (aria-labelledby); a press focuses the trigger without opening.
trigger(options?)buttondisabled.
value()spanHost for ValueText.
<ValueText>(text)placeholder, children(value). Renders the label(s); no element of its own.
icon()any
popup(options?)divalignItemWithTrigger (true), side, align, sideOffset, alignOffset, collisionPadding, arrowPadding, anchor, initialFocus, finalFocus.
list()divWithout it, the popup becomes the listbox (after hydration).
<ItemContext>—value, label, disabled. Required for itemIndicator(); item() inside it reads its options from it.
item(options?)divvalue, label (typeahead / display text), disabled.
itemText()anyThe item's text: used as its label instead of the full text content.
itemIndicator(options?)anykeepMounted.
group() / groupLabel()div
separator()divThe generic stems-ui/separator mixin.
scrollUpArrow(options?) / scrollDownArrow(options?)divkeepMounted. Scroll the list while hovered (not on touch).
arrow(), backdrop()div

Base UI's Portal and Positioner have no Stems equivalent: the popup is shown in the top layer where it is rendered, and the positioner options go to popup().

Events
  • stems:select-change → SelectChangeEvent (value, reason), from the trigger. Listener: onSelectChange(handler).
  • stems:select-open-change → SelectOpenChangeEvent (open, reason). Listener: onSelectOpenChange(handler).

Behavior

  • Keyboard (trigger): Enter, Space, ArrowDown open (highlighting the selected item, else the first); ArrowUp opens on the selected item, else the last. Typing selects the next matching item without opening (single selection; Space continues a search in progress).
  • Keyboard (list): ArrowDown / ArrowUp, Home / End (disabled items are skipped; no wrap unless loopFocus), Enter / Space select, typing highlights the next match (repeat a letter to cycle), Escape closes, Tab closes and moves to the next tabbable after the trigger.
  • Single: pressing an item selects it and closes the popup; focus returns to the trigger. Multiple: pressing toggles it and the popup stays open.
  • Focus: items get real DOM focus (tabIndex={-1}); data-highlighted follows focus and the pointer. Opening with the pointer and no value focuses the list.
  • Positioning: with alignItemWithTrigger (default) the popup overlaps the trigger so the selected item (or the first item) lines up with the value text, and data-side is "none". It falls back to regular placement (side / align, default below) when opened by touch or when the trigger is within 20px of the top or bottom of the viewport. The mode is decided once per opening.
  • Forms: the provider renders a visually hidden <input> carrying name, the value, required, disabled, form, autoComplete; with multiple, one <input type="hidden"> per value (and the unnamed validation input). Focusing the input (e.g. an invalid submit) focuses the trigger.
  • Labels: ValueText shows, in order: items, itemToStringLabel, the registered item's label / item text, an object value's label, the string value. Items register on the client, so pass items (or itemToStringLabel) for a correct label in server-rendered HTML; without it the server renders the raw value and the label appears after hydration.

Styling hooks

PartData attributesCSS variables
triggerdata-popup-open, data-pressed, data-placeholder, data-disabled, data-readonly, data-required; aria-expanded
valuedata-placeholder
icondata-popup-open
popupdata-open / data-closed, data-side (none when aligned), data-align, data-starting-style / data-ending-style--anchor-width, --anchor-height, --available-width, --available-height, --transform-origin
itemdata-selected, data-highlighted, data-disabled; aria-selected
itemIndicatordata-selected, data-starting-style / data-ending-style
scroll arrowsdata-direction (up / down), data-visible, data-side
arrowdata-open / data-closed, data-side, data-align, data-uncentered
backdropdata-open / data-closed, data-starting-style / data-ending-style
separatordata-orientation

Scroll arrows without their own position become position: sticky overlays (with a negative margin, so showing them doesn't shift the items) inside the scrolling element, or position: absolute when placed outside it. Style top: 0 / bottom: 0.

Inside a field, the trigger also gets data-valid, data-invalid, data-dirty, data-touched, data-filled, data-focused, and aria-invalid="true" while invalid.

Field integration

Inside <field.Context> the select is the field's control:

  • The trigger gets the field's control id (unless it has its own id, which the field then follows), so <label mix={field.label()}> targets it; it is aria-labelledby the select.label() or else the field label, and aria-describedby the field's descriptions and visible errors. A native <label> click also activates the trigger (opens the popup); use a non-<label> host (or select.label()) to only focus it.
  • The field's name wins for the hidden input(s). required and the field's validate (called with the value) run on the visually hidden input; the trigger is focused when the field blocks a form submission. Server errors show until the value changes.
  • Focus moving between the trigger and the popup stays inside the field: data-focused stays and data-touched (and validationMode="onBlur") waits until focus leaves both.
  • The field's (or an enclosing fieldset.Context's) disabled disables the select.

Not implemented (yet)

  • Press-drag-release selection (open on pointer down, select on pointer up), actionsRef, inputRef, the trigger's data-popup-side, disableAnchorTracking, collisionAvoidance.
  • Item-aligned mode doesn't grow the popup while scrolling like Base UI; the list scrolls inside the aligned popup.
  • Browser autofill writing into the hidden input isn't read back.

menu wires a trigger button to a popup list of actions: menu roles, keyboard navigation with typeahead, highlight on hover, checkbox and radio items, submenus, and the popover machinery (top layer, positioning, dismissal, focus, enter/exit animation hooks). App code owns the markup and visual styles.

Base UI equivalent: Menu. Context menus are stems-ui/context-menu, which shares every part below.

import * as menu from 'stems-ui/menu'

let itemClass =
  'flex px-3 py-1.5 outline-none data-highlighted:bg-gray-100 data-disabled:opacity-50'

function ViewMenu(handle: Handle) {
  let sort = 'date'
  return () => (
    <menu.Context>
      <button mix={menu.trigger()}>View</button>
      <div
        class="min-w-40 rounded-md bg-white p-1 shadow-md data-starting-style:opacity-0 data-ending-style:opacity-0 transition-opacity"
        mix={menu.popup({ align: 'start', sideOffset: 4 })}
      >
        <div class={itemClass} mix={menu.item({ value: 'reload' })}>
          Reload
        </div>
        <div class="my-1 h-px bg-gray-200" mix={menu.separator()} />

        <menu.CheckboxItemContext defaultChecked>
          <div class={itemClass} mix={menu.checkboxItem()}>
            <span mix={menu.checkboxItemIndicator()}>✓</span> Word wrap
          </div>
        </menu.CheckboxItemContext>

        <menu.RadioGroupContext
          value={sort}
          onValueChange={(value) => {
            sort = String(value)
            void handle.update()
          }}
        >
          <div mix={menu.radioGroup()}>
            <div mix={menu.groupLabel()}>Sort by</div>
            <menu.RadioItemContext value="date">
              <div class={itemClass} mix={menu.radioItem()}>
                <span mix={menu.radioItemIndicator()}>•</span> Date
              </div>
            </menu.RadioItemContext>
            <menu.RadioItemContext value="name">
              <div class={itemClass} mix={menu.radioItem()}>
                <span mix={menu.radioItemIndicator()}>•</span> Name
              </div>
            </menu.RadioItemContext>
          </div>
        </menu.RadioGroupContext>

        <menu.SubmenuContext>
          <div class={itemClass} mix={menu.submenuTrigger()}>
            Zoom ›
          </div>
          <div class="rounded-md bg-white p-1 shadow-md" mix={menu.popup()}>
            <div class={itemClass} mix={menu.item({ value: 'zoom-in' })}>
              Zoom in
            </div>
            <div class={itemClass} mix={menu.item({ value: 'zoom-out' })}>
              Zoom out
            </div>
          </div>
        </menu.SubmenuContext>

        <a class={itemClass} href="/settings" mix={menu.linkItem()}>
          Settings
        </a>
      </div>
    </menu.Context>
  )
}

Listen for activations anywhere above the items:

<div mix={menu.onMenuSelect((event) => console.log(event.kind, event.value, event.checked))}>…</div>
  • No Portal, no Positioner element. The popup is shown in the top layer with popover="manual"; Base UI's Positioner props are options of popup() and the positioner's data attributes / CSS variables are set on the popup.
  • Item state lives in providers. A mixin can't provide context to its children, so the stateful items are split like accordion.ItemContext: CheckboxItemContext (Base UI CheckboxItem's checked / defaultChecked / onCheckedChange) + checkboxItem(), RadioGroupContext (RadioGroup's value / defaultValue / onValueChange) + radioGroup(), RadioItemContext (RadioItem's value) + radioItem(). The indicators read the nearest item provider.
  • Submenus must be rendered inside the parent's popup (in the DOM): SubmenuContext = Base UI SubmenuRoot.
  • Indicators always stay in the DOM (hidden while unchecked). keepMounted keeps them visible instead.
  • MenuSelectEvent (stems:menu-select) is a Stems extra (Remix had onMenuSelect); items accept a value option reported by it.
  • Filtering (Base UI 1.9 Menu.FilterProvider / Input / Clear / Empty / List): see Filtering. Empty is a live region that stays rendered (put the message in EmptyText), and Clear gets hidden instead of unmounting. list() without a FilterProvider is a plain role="presentation" wrapper (the popup keeps role="menu").
  • Not supported: the Viewport part, actionsRef, data-instant, --positioner-width/height, the 'sibling-open' / 'cancel-open' reasons (a sibling opening reports 'none'), opening on pointer down with press-drag-release selection, and outside-pointer blocking beyond inert.
  • Context (Base UI Menu.Root): owns the open state. Read it with handle.context.get(menu.Context) (open, setOpen(open, { reason }), close(), highlighted, store, …).
    • open / defaultOpen / onOpenChange(open, details): details has reason, event, trigger, cancel().
    • onOpenChangeComplete(open): after the enter / exit animation.
    • modal: true (default) | false | 'trap-focus'. true locks page scroll and makes outside content inert. A menu opened by hovering its trigger is never modal.
    • loopFocus: boolean, default true. Arrow keys wrap at the ends.
    • orientation: 'vertical' (default) | 'horizontal'. Which arrows move between items.
    • disabled: disables the trigger.
    • highlightItemOnHover: boolean, default true.
    • onItemHighlighted(item | undefined, { reason: 'keyboard' | 'pointer' | 'none', event, label }).
  • SubmenuContext (Base UI Menu.SubmenuRoot): same props except modal (submenus are never modal), plus closeParentOnEsc (default false: Escape closes only the submenu).
  • CheckboxItemContext: checked / defaultChecked (default false) / onCheckedChange(checked, details) (details.cancel() vetoes), disabled.
  • RadioGroupContext: value / defaultValue / onValueChange(value, details), disabled.
  • RadioItemContext: value (required), disabled.
  • trigger(options?): type="button", id, aria-haspopup="menu", aria-expanded, aria-controls. Options: disabled, openOnHover, delay (default 100), closeDelay (default 0).
  • popup(options?): role="menu", aria-labelledby (the trigger), aria-orientation when horizontal, popover="manual", tabIndex={-1}, hidden while closed. Options: side (default 'bottom'; submenus 'inline-end'), align (default 'center'; submenus 'start'), sideOffset, alignOffset, collisionPadding, arrowPadding, anchor, initialFocus, finalFocus (see popover).
  • arrow(), backdrop(): as in popover.
  • item(options?): role="menuitem", tabIndex={-1}. Options: label (typeahead text, defaults to the text content), disabled, closeOnClick (default true), value.
  • linkItem(options?): for an <a href>. Options: label, closeOnClick (default false).
  • checkboxItem(options?): role="menuitemcheckbox", aria-checked. Options: label, closeOnClick (default false), value.
  • checkboxItemIndicator(options?) / radioItemIndicator(options?): aria-hidden; hidden once unchecked (after its exit animation) unless keepMounted.
  • radioGroup(): role="group".
  • radioItem(options?): role="menuitemradio", aria-checked. Options: label, closeOnClick (default false).
  • group(): role="group", aria-labelledby its groupLabel() (set after hydration).
  • groupLabel(): an id for the enclosing group.
  • separator(options?): the generic stems-ui/separator mixin.
  • submenuTrigger(options?): role="menuitem", aria-haspopup="menu", aria-expanded, aria-controls. Options: label, disabled, openOnHover (default true), delay (default 100), closeDelay (default 0).
  • MenuChangeEvent (stems:menu-change, onMenuChange(handler)): bubbles from the active trigger (a submenu's from its submenu trigger) with open and reason.
  • MenuSelectEvent (stems:menu-select, onMenuSelect(handler)): bubbles from an activated item, after checkbox / radio state changed, with item (the element), kind ('item' | 'checkbox-item' | 'radio-item' | 'link-item'), label, value (the radio value; the href for links), checked, and closeOnClick. It is cancelable: preventDefault() keeps the menu open.

Change reasons: 'trigger-press', 'trigger-hover', 'outside-press', 'escape-key', 'item-press', 'list-navigation', 'focus-out', 'imperative-action', 'none'.

Base UI's createHandle(): triggers can live anywhere in the document, outside the Context, linked by a handle. Each trigger can pass a payload, so one popup can serve many triggers with different data.

import * as menu from 'stems-ui/menu'

let people = menu.createHandle<{ name: string }>()

// Anywhere, e.g. in a table row:
<button mix={menu.trigger({ handle: people, payload: { name: 'Ada' } })}>Ada</button>
<button mix={menu.trigger({ handle: people, payload: { name: 'Grace' } })}>Grace</button>

// Elsewhere:
<menu.Context handle={people}>
  {({ payload }) => <div mix={menu.popup()}>{payload?.name}</div>}
</menu.Context>
  • createHandle<Payload>() returns the handle. Pass it to the Context (handle prop, read once) and to each detached trigger({ handle, payload }) (read on the trigger's first render). Payload types payload in the Context's children function.
  • Detached triggers register with the handle, so they behave like triggers inside the Context: aria-expanded, aria-controls, data-popup-open, and data-pressed follow the menu, the popup is labelled by the active trigger, ArrowDown / ArrowUp on a trigger open the menu, the popup anchors to the active trigger, focus returns to it, and a press on another trigger counts as inside (it moves the popup instead of dismissing it).
  • The active trigger's payload is { payload } in a children render function (re-run each time the Context renders) and payload on the context value (handle.context.get(menu.Context).payload). It's undefined until a trigger has been active, and stays while the popup animates out. A children function only works with JSX (createElement rejects function children). payload also works on triggers inside the Context.
  • Handle methods: isOpen, open(triggerId?) (opens, or moves an open popup, to the trigger with that id; reason 'imperative-action'), close(). They act on the mounted Context and are ignored while none is mounted. A remounted Context starts fresh.
  • Context props triggerId (controlled, pair with open; store details.trigger?.id from onOpenChange) and defaultTriggerId pick the active trigger by element id.
  • Detached triggers aren't re-rendered by the Context; they update their state attributes directly when it renders. On the server nothing is shared (a handle is usually a module-level singleton), so a detached trigger renders closed and without aria-controls; it gets aria-controls once the Context mounts in the browser.
  • Only top-level menu.Context takes a handle (not submenus, context menus, or menus inside a menubar.Context).
<menu.FilterProvider autoHighlight>
  <menu.Context>
    <button mix={menu.trigger()}>Move to…</button>
    <div mix={menu.popup()}>
      <input aria-label="Filter folders" mix={menu.input()} />
      <button mix={menu.clear()}>Clear</button>
      <div mix={menu.empty()}>
        <menu.EmptyText>No folders found.</menu.EmptyText>
      </div>
      <div mix={menu.list()}>
        <div mix={menu.item()}>Inbox</div>
        <div mix={menu.item({ label: 'Résumés' })}>CVs</div>
      </div>
    </div>
  </menu.Context>
</menu.FilterProvider>
  • FilterProvider (Base UI Menu.FilterProvider) wraps a Context or a SubmenuContext. The first menu inside it owns it, so a submenu stays unfiltered unless it has its own provider. Props:
    • value / defaultValue (default '') / onValueChange(value, details): the query. details has reason ('input-change', 'input-clear', 'clear-press', 'popup-close'), event, and cancel().
    • autoHighlight: false (default), true (highlight the first match while the query isn't empty), or 'always' (also when it is empty).
    • filter(text, query): replaces the matching. It gets each item's label (or text) and the trimmed query. The default keeps items that contain the query, ignoring case and diacritics (the same containsText() combobox uses, exported with startsWithText()). null hides nothing, for filtering the rendered items yourself.
    • locale: for the default matching.
  • input() (Base UI Menu.Input): type="text", role="searchbox", autocomplete="off", aria-controls (the list), aria-activedescendant (the highlighted item; items get an id when first highlighted if they have none), data-highlighted while focused and no item is highlighted by the keyboard. Its value follows the query.
  • clear(options?) (Base UI Menu.Clear): type="button", tabIndex={-1}, aria-hidden, hidden while the query is empty, data-disabled. A press clears the query (reason 'clear-press') and refocuses the input; mousedown is prevented so focus stays in the input. Option: disabled.
  • empty() (Base UI Menu.Empty): role="status", aria-live="polite", aria-atomic, data-empty while no item of this menu is shown. EmptyText renders its children only then. Neither shows anything in server HTML.
  • list() (Base UI Menu.List): role="menu", an id (default <popup id>-list), tabIndex={-1}, aria-labelledby (the trigger).
  • onMenuFilterChange(handler): MenuFilterChangeEvent (stems:menu-filter-change, from the input, bubbles) with value and reason.

Behavior:

  • With a FilterProvider, the trigger has aria-haspopup="dialog" and the popup role="dialog" (it holds the searchbox and the menu list), as in Base UI.
  • Items, submenu triggers, and groups whose items all fail to match get hidden; they stay mounted. If your styles set display on a group or item, add a [hidden] rule.
  • Opening with a click or the keyboard focuses the input; a keyboard open also highlights the first item. Opening on hover, touch, or pen focuses the popup instead, so no on-screen keyboard shows up.
  • Focus stays in the input: ArrowDown / ArrowUp move the highlight (virtual focus, wrapping with loopFocus); Enter activates the highlighted item. Home / End jump to the ends only while the input is empty and an item is highlighted, and ArrowLeft / ArrowRight move the caret unless the input is empty or the highlighted item opens a submenu (then they open / close it). Characters and modified keys edit the text: typeahead is off. Tab / Shift+Tab close the menu as usual.
  • Pointer highlights don't move focus out of the input.
  • After the query changes, a highlighted item that no longer matches loses the highlight, and autoHighlight highlights the first match.
  • The query resets to '' (reason 'popup-close') once the menu has finished closing. Cancel that change to keep it.
  • Labels come from the rendered text, so server HTML shows every item even with a defaultValue; items are hidden once they mount in the browser.
  • Opening: a pointer press on the trigger focuses the popup with nothing highlighted; Enter / Space / ArrowDown focus the first enabled item, ArrowUp the last.
  • Keyboard in the popup: ArrowDown / ArrowUp (ArrowRight / ArrowLeft when horizontal, mirrored in RTL) move between enabled items, wrapping with loopFocus; Home / End; printable keys typeahead (repeat a letter to cycle); Enter / Space activate (they call the item's click()); Escape closes the topmost menu; Tab closes the whole tree and moves focus past the trigger (Shift+Tab to the trigger).
  • Submenus: ArrowRight (ArrowLeft in RTL), Enter, or Space on a submenu trigger opens the submenu and focuses its first item; ArrowLeft (ArrowRight in RTL) closes it and refocuses the trigger. Hovering opens it after delay without moving focus; the pointer can cross sibling items on its way to the submenu (hover aim). A press on the trigger opens it (and keeps a hover-opened submenu open). Highlighting another parent item closes it. Escape closes only the submenu unless closeParentOnEsc. Opening a submenu closes its open siblings; closing a menu closes its submenus.
  • Activation: item() closes the whole tree by default (reason 'item-press'); checkbox, radio and link items stay open unless closeOnClick. Focus returns to the trigger.
  • Highlight: items get real DOM focus. data-highlighted follows focus and pointer moves (not touch). When the pointer leaves an item for a non-item, the highlight clears and focus moves to the popup (a keyboard highlight is kept).
  • Dismissal: shared dismiss-layer stack. Pressing inside the parent closes an open submenu; pressing outside closes the tree. With modal (the default) everything outside the popup is inert, so pressing the trigger is an outside press.
  • Closed popups and unchecked indicators get hidden in server HTML; aria-checked, data-checked / data-unchecked, and data-disabled are correct before hydration.
PartAttribute / variableValues / notes
triggerdata-popup-open, data-pressedpresent while open
triggerdata-disabledpresent when disabled
popupdata-open / data-closedopen state
popupdata-starting-style / data-ending-styleenter / exit transition
popup, arrowdata-sidetop bottom left right inline-start inline-end (final side)
popup, arrowdata-alignstart center end
popup--anchor-width, --anchor-heighttrigger size
popup--available-width, --available-heightspace to the viewport edges
popup--transform-originfor scale animations
arrowdata-uncentered, data-open / data-closed
backdropdata-open / data-closed, presence attrs
item, link, sub-triggerdata-highlightedpresent while highlighted
item, checkbox, radio, sub-triggerdata-disabledpresent when disabled
checkbox / radio itemdata-checked / data-uncheckedchecked state (also aria-checked)
indicatorsdata-checked / data-unchecked, data-disabled, presence attrs
radio groupdata-disabledpresent when disabled
sub-triggerdata-popup-openpresent while its submenu is open
separatordata-orientationhorizontal / vertical
filter inputdata-highlightedfocused, and no item is highlighted by the keyboard
filter cleardata-disabled, hiddenhidden while the query is empty
filter emptydata-emptypresent while no item matches
item, sub-trigger, grouphiddenset by the filter for non-matching items