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/toolbarRequires 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 thestems-ui/buttonbehavior, sodisabledset here keeps the element focusable (aria-disabled) and blocks its activation, as in Base UI: setdisabledon 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 UIToolbar.Root):orientation:'horizontal'(default; Left/Right, mirrored in RTL) or'vertical'(Up/Down).loopFocus: wrap arrow-key focus at the ends. Defaults totrue.disabled: disable every item. Items stay focusable unlessfocusableWhenDisabled: false.handle.context.get(toolbar.Context)returns{ orientation, loopFocus, disabled }.
GroupContext:disabledfor the items inside a group. Base UI'sToolbar.Grouptakesdisableditself; a mixin can't pass state to the elements inside it, so the prop lives on this provider (wrap thegroup()element in it).root():role="toolbar",aria-orientation,data-orientation,data-disabled, and the keyboard handling. Name it witharia-labeloraria-labelledby.button(options): a toolbar item with button behavior (type="button", orrole="button"and Enter/Space on other hosts). Options:disabled: also set by the toolbar or the group.focusableWhenDisabled: defaulttrue. A disabled button getsaria-disabledand stays in the arrow-key order; withfalseit gets the nativedisabledattribute 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(defaulttrue:aria-disabled+readOnly, so the input stays focusable but can't be edited;false: nativedisabled). 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'sdisabledapply) andfocusableWhenDisabled(defaulttrue).group():role="group",data-orientation,data-disabled.separator(options):stems-ui/separator, withorientationdefaulting 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
focusableWhenDisabledisfalse; natively disabled elements (:disabled, e.g. adisabledtoggle button composed withtoolbar.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
renderprop has no Stems equivalent; compose mixins instead (see above).
| Key | Action |
|---|---|
ArrowRight / ArrowLeft | Next / previous item (horizontal; mirrored in RTL) |
ArrowDown / ArrowUp | Next / previous item (vertical) |
Home / End | First / last item (not inside an input) |
Tab | Leaves the toolbar |
Styling hooks
| Part | Attribute | Present when / value |
|---|---|---|
| root | data-orientation | "horizontal" or "vertical" |
| root | data-disabled | The toolbar is disabled |
| button | data-orientation | The toolbar's orientation |
| button | data-disabled | The button, its group, or the toolbar is disabled |
| button | data-focusable | focusableWhenDisabled is true (the default) |
| input | data-orientation | The toolbar's orientation |
| input | data-disabled | The input, its group, or the toolbar is disabled |
| input | data-focusable | focusableWhenDisabled is true (the default) |
| link | data-orientation | The toolbar's orientation |
| item | data-orientation | The toolbar's orientation |
| group | data-orientation | The toolbar's orientation |
| group | data-disabled | The group or the toolbar is disabled |
| separator | data-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'sToggleGroup):value: pressed button values. Controlled when notundefined.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 tofalse: 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 totrue.handle.context.get(toggleButtonGroup.Context)returns{ value, multiple, disabled, orientation, loopFocus }.
root(): setsrole="group",data-orientation,data-disabled, anddata-multiple. Name it witharia-labeloraria-labelledby.ToggleButtonGroupChangeEvent: bubblingstems:toggle-button-group-changeevent with the nextvalue, dispatched from the button that changed. The button also dispatches its ownstems: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 ownpressed/defaultPressedare ignored,onPressedChangestill fires, and the group'sdisabledapplies. Give every button avalue; 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
reasonis always'none', so Stems doesn't pass one.
| Key | Action |
|---|---|
ArrowRight / ArrowLeft | Next / previous button (horizontal; mirrored in RTL) |
ArrowDown / ArrowUp | Next / previous button (vertical) |
Home / End | First / last enabled button |
Space / Enter | Press or release the focused button |
Styling hooks
| Part | Attribute | Present when / value |
|---|---|---|
| root | data-orientation | "horizontal" or "vertical" |
| root | data-disabled | disabled is set |
| root | data-multiple | multiple is set |
| button | data-pressed | The button is pressed |
| button | data-disabled | The 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).
| Prop | Default | Notes |
|---|---|---|
value / defaultValue | null | A 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. | |
multiple | false | |
open / defaultOpen | false | |
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. | |
modal | true | Locks scroll, makes outside content inert, traps focus. 'trap-focus' or false to relax. |
name, form, autoComplete | Form participation. | |
required | false | Native constraint validation; an invalid submit focuses the trigger. |
disabled, readOnly | false | readOnly 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 / itemToStringValue | Label for display / string for the form. Objects with label / value keys work without them. | |
isItemEqualToValue | Object.is | |
highlightItemOnHover | true | |
loopFocus | false | Stems 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
| Part | Element | Options |
|---|---|---|
label() | any | Labels the trigger and list (aria-labelledby); a press focuses the trigger without opening. |
trigger(options?) | button | disabled. |
value() | span | Host for ValueText. |
<ValueText> | (text) | placeholder, children(value). Renders the label(s); no element of its own. |
icon() | any | |
popup(options?) | div | alignItemWithTrigger (true), side, align, sideOffset, alignOffset, collisionPadding, arrowPadding, anchor, initialFocus, finalFocus. |
list() | div | Without 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?) | div | value, label (typeahead / display text), disabled. |
itemText() | any | The item's text: used as its label instead of the full text content. |
itemIndicator(options?) | any | keepMounted. |
group() / groupLabel() | div | |
separator() | div | The generic stems-ui/separator mixin. |
scrollUpArrow(options?) / scrollDownArrow(options?) | div | keepMounted. 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-highlightedfollows 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, anddata-sideis"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>carryingname, the value,required,disabled,form,autoComplete; withmultiple, one<input type="hidden">per value (and the unnamed validation input). Focusing the input (e.g. an invalid submit) focuses the trigger. - Labels:
ValueTextshows, in order:items,itemToStringLabel, the registered item'slabel/ item text, an object value'slabel, the string value. Items register on the client, so passitems(oritemToStringLabel) for a correct label in server-rendered HTML; without it the server renders the raw value and the label appears after hydration.
Styling hooks
| Part | Data attributes | CSS variables |
|---|---|---|
| trigger | data-popup-open, data-pressed, data-placeholder, data-disabled, data-readonly, data-required; aria-expanded | |
| value | data-placeholder | |
| icon | data-popup-open | |
| popup | data-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 |
| item | data-selected, data-highlighted, data-disabled; aria-selected | |
| itemIndicator | data-selected, data-starting-style / data-ending-style | |
| scroll arrows | data-direction (up / down), data-visible, data-side | |
| arrow | data-open / data-closed, data-side, data-align, data-uncentered | |
| backdrop | data-open / data-closed, data-starting-style / data-ending-style | |
| separator | data-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 isaria-labelledbytheselect.label()or else the field label, andaria-describedbythe field's descriptions and visible errors. A native<label>click also activates the trigger (opens the popup); use a non-<label>host (orselect.label()) to only focus it. - The field's
namewins for the hidden input(s).requiredand the field'svalidate(called with the value) run on the visually hidden input; the trigger is focused when the field blocks a form submission. Servererrorsshow until the value changes. - Focus moving between the trigger and the popup stays inside the field:
data-focusedstays anddata-touched(andvalidationMode="onBlur") waits until focus leaves both. - The field's (or an enclosing
fieldset.Context's)disableddisables the select.
Not implemented (yet)
- Press-drag-release selection (open on pointer down, select on pointer up),
actionsRef,inputRef, the trigger'sdata-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.
stems-ui/menu
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.
Usage
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>
Differences from Base UI
- No Portal, no Positioner element. The popup is shown in the top layer with
popover="manual"; Base UI's Positioner props are options ofpopup()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'schecked/defaultChecked/onCheckedChange) +checkboxItem(),RadioGroupContext(RadioGroup'svalue/defaultValue/onValueChange) +radioGroup(),RadioItemContext(RadioItem'svalue) +radioItem(). The indicators read the nearest item provider. - Submenus must be rendered inside the parent's popup (in the DOM):
SubmenuContext= Base UISubmenuRoot. - Indicators always stay in the DOM (
hiddenwhile unchecked).keepMountedkeeps them visible instead. MenuSelectEvent(stems:menu-select) is a Stems extra (Remix hadonMenuSelect); items accept avalueoption reported by it.- Filtering (Base UI 1.9
Menu.FilterProvider/Input/Clear/Empty/List): see Filtering.Emptyis a live region that stays rendered (put the message inEmptyText), andCleargetshiddeninstead of unmounting.list()without aFilterProvideris a plainrole="presentation"wrapper (the popup keepsrole="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 beyondinert.
stems-ui/menu
Providers
Context(Base UIMenu.Root): owns the open state. Read it withhandle.context.get(menu.Context)(open,setOpen(open, { reason }),close(),highlighted,store, …).open/defaultOpen/onOpenChange(open, details):detailshasreason,event,trigger,cancel().onOpenChangeComplete(open): after the enter / exit animation.modal:true(default) |false|'trap-focus'.truelocks page scroll and makes outside contentinert. A menu opened by hovering its trigger is never modal.loopFocus:boolean, defaulttrue. Arrow keys wrap at the ends.orientation:'vertical'(default) |'horizontal'. Which arrows move between items.disabled: disables the trigger.highlightItemOnHover:boolean, defaulttrue.onItemHighlighted(item | undefined, { reason: 'keyboard' | 'pointer' | 'none', event, label }).
SubmenuContext(Base UIMenu.SubmenuRoot): same props exceptmodal(submenus are never modal), pluscloseParentOnEsc(defaultfalse: Escape closes only the submenu).CheckboxItemContext:checked/defaultChecked(defaultfalse) /onCheckedChange(checked, details)(details.cancel()vetoes),disabled.RadioGroupContext:value/defaultValue/onValueChange(value, details),disabled.RadioItemContext:value(required),disabled.
Mixins
trigger(options?):type="button",id,aria-haspopup="menu",aria-expanded,aria-controls. Options:disabled,openOnHover,delay(default100),closeDelay(default0).popup(options?):role="menu",aria-labelledby(the trigger),aria-orientationwhen horizontal,popover="manual",tabIndex={-1},hiddenwhile 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(defaulttrue),value.linkItem(options?): for an<a href>. Options:label,closeOnClick(defaultfalse).checkboxItem(options?):role="menuitemcheckbox",aria-checked. Options:label,closeOnClick(defaultfalse),value.checkboxItemIndicator(options?)/radioItemIndicator(options?):aria-hidden;hiddenonce unchecked (after its exit animation) unlesskeepMounted.radioGroup():role="group".radioItem(options?):role="menuitemradio",aria-checked. Options:label,closeOnClick(defaultfalse).group():role="group",aria-labelledbyitsgroupLabel()(set after hydration).groupLabel(): anidfor the enclosing group.separator(options?): the genericstems-ui/separatormixin.submenuTrigger(options?):role="menuitem",aria-haspopup="menu",aria-expanded,aria-controls. Options:label,disabled,openOnHover(defaulttrue),delay(default100),closeDelay(default0).
Events
MenuChangeEvent(stems:menu-change,onMenuChange(handler)): bubbles from the active trigger (a submenu's from its submenu trigger) withopenandreason.MenuSelectEvent(stems:menu-select,onMenuSelect(handler)): bubbles from an activated item, after checkbox / radio state changed, withitem(the element),kind('item'|'checkbox-item'|'radio-item'|'link-item'),label,value(the radio value; thehreffor links),checked, andcloseOnClick. 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'.
Detached triggers
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 theContext(handleprop, read once) and to each detachedtrigger({ handle, payload })(read on the trigger's first render).Payloadtypespayloadin theContext's children function.- Detached triggers register with the handle, so they behave like triggers inside the
Context:aria-expanded,aria-controls,data-popup-open, anddata-pressedfollow 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
payloadis{ payload }in a children render function (re-run each time theContextrenders) andpayloadon the context value (handle.context.get(menu.Context).payload). It'sundefineduntil a trigger has been active, and stays while the popup animates out. A children function only works with JSX (createElementrejects function children).payloadalso works on triggers inside theContext. - Handle methods:
isOpen,open(triggerId?)(opens, or moves an open popup, to the trigger with thatid; reason'imperative-action'),close(). They act on the mountedContextand are ignored while none is mounted. A remountedContextstarts fresh. ContextpropstriggerId(controlled, pair withopen; storedetails.trigger?.idfromonOpenChange) anddefaultTriggerIdpick the active trigger by elementid.- 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 withoutaria-controls; it getsaria-controlsonce theContextmounts in the browser. - Only top-level
menu.Contexttakes ahandle(not submenus, context menus, or menus inside amenubar.Context).
Filtering
<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 UIMenu.FilterProvider) wraps aContextor aSubmenuContext. 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.detailshasreason('input-change','input-clear','clear-press','popup-close'),event, andcancel().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'slabel(or text) and the trimmed query. The default keeps items that contain the query, ignoring case and diacritics (the samecontainsText()combobox uses, exported withstartsWithText()).nullhides nothing, for filtering the rendered items yourself.locale: for the default matching.
input()(Base UIMenu.Input):type="text",role="searchbox",autocomplete="off",aria-controls(the list),aria-activedescendant(the highlighted item; items get anidwhen first highlighted if they have none),data-highlightedwhile focused and no item is highlighted by the keyboard. Itsvaluefollows the query.clear(options?)(Base UIMenu.Clear):type="button",tabIndex={-1},aria-hidden,hiddenwhile the query is empty,data-disabled. A press clears the query (reason'clear-press') and refocuses the input;mousedownis prevented so focus stays in the input. Option:disabled.empty()(Base UIMenu.Empty):role="status",aria-live="polite",aria-atomic,data-emptywhile no item of this menu is shown.EmptyTextrenders its children only then. Neither shows anything in server HTML.list()(Base UIMenu.List):role="menu", anid(default<popup id>-list),tabIndex={-1},aria-labelledby(the trigger).onMenuFilterChange(handler):MenuFilterChangeEvent(stems:menu-filter-change, from the input, bubbles) withvalueandreason.
Behavior:
- With a
FilterProvider, the trigger hasaria-haspopup="dialog"and the popuprole="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 setdisplayon 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
autoHighlighthighlights 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.
Behavior notes
- 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'sclick()); 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
delaywithout 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 unlesscloseParentOnEsc. 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 unlesscloseOnClick. Focus returns to the trigger. - Highlight: items get real DOM focus.
data-highlightedfollows 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 isinert, so pressing the trigger is an outside press. - Closed popups and unchecked indicators get
hiddenin server HTML;aria-checked,data-checked/data-unchecked, anddata-disabledare correct before hydration.
Styling hooks
| Part | Attribute / variable | Values / notes |
|---|---|---|
| trigger | data-popup-open, data-pressed | present while open |
| trigger | data-disabled | present when disabled |
| popup | data-open / data-closed | open state |
| popup | data-starting-style / data-ending-style | enter / exit transition |
| popup, arrow | data-side | top bottom left right inline-start inline-end (final side) |
| popup, arrow | data-align | start center end |
| popup | --anchor-width, --anchor-height | trigger size |
| popup | --available-width, --available-height | space to the viewport edges |
| popup | --transform-origin | for scale animations |
| arrow | data-uncentered, data-open / data-closed | |
| backdrop | data-open / data-closed, presence attrs | |
| item, link, sub-trigger | data-highlighted | present while highlighted |
| item, checkbox, radio, sub-trigger | data-disabled | present when disabled |
| checkbox / radio item | data-checked / data-unchecked | checked state (also aria-checked) |
| indicators | data-checked / data-unchecked, data-disabled, presence attrs | |
| radio group | data-disabled | present when disabled |
| sub-trigger | data-popup-open | present while its submenu is open |
| separator | data-orientation | horizontal / vertical |
| filter input | data-highlighted | focused, and no item is highlighted by the keyboard |
| filter clear | data-disabled, hidden | hidden while the query is empty |
| filter empty | data-empty | present while no item matches |
| item, sub-trigger, group | hidden | set by the filter for non-matching items |