Menu
Action menu for invoking actions. Items dispatch onSelect callbacks; an item with checked displays a setting. For committing a value to a form, use Select.
Playground
—<script lang="ts">
import { Menu } from '@urbicon-ui/blocks';
const items = [
{ label: 'Dashboard', onSelect: () => console.log('Dashboard') },
{ label: 'User Settings', onSelect: () => console.log('User Settings') },
{ label: 'Notifications', onSelect: () => console.log('Notifications') },
{ label: 'Billing', onSelect: () => console.log('Billing') },
{ label: 'Help', onSelect: () => console.log('Help') }
];
</script>
<Menu
{items}
intent="neutral"
itemSize=""
placeholder="Actions"
size="md"
variant="outlined"
/>01 When to use Menu
Menu invokes actions. To pick a value that binds to a form, reach for Select or Combobox instead.
| Component | Role | Reach for it when |
|---|---|---|
Menu (this) | menu | The items are verbs: Edit, Delete, Share, Export. Each runs its onSelect. An item given checked also displays a setting — Menu shows the state
you supply but never stores a selection. |
Select | listbox | The user commits a value to a form. Single or multiple. |
Combobox | listbox | A value from a long list, narrowed by type-ahead. |
02 Examples
Each item is an object with a label and an onSelect that runs when it is activated (a bare string is
shorthand for a label-only item). Add id, disabled, keepOpen for repeated picks, checked for a selectable setting, detail for a right-aligned readout, or children for a submenu, and a type: 'section' entry heads a group and owns every item
up to the next header; { type: 'divider' } draws a rule between two
runs. Build the menu from an items array, or declaratively with <MenuItem>, <MenuSection> and <MenuDivider> children — there a section takes the
items it names as its own children. When the built-in icon-label-detail row is not enough, a customItem snippet takes over each row's inner content — render
visible content only, since Menu supplies the surrounding button.
Basic actions
—<script lang="ts">
import { Menu, MenuDivider, MenuItem, MenuSection } from '@urbicon-ui/blocks';
let lastAction = $state('—');
</script>
<div class="flex items-center gap-4">
<Menu placeholder="File">
<MenuItem label="New file" onSelect={() => (lastAction = 'New file')} />
<MenuItem label="Open recent" onSelect={() => (lastAction = 'Open recent')} />
<MenuSection label="Workspace">
<MenuItem label="Settings" onSelect={() => (lastAction = 'Settings')} />
<MenuItem label="Extensions" onSelect={() => (lastAction = 'Extensions')} />
</MenuSection>
<MenuDivider />
<MenuItem label="Close window" onSelect={() => (lastAction = 'Close window')} />
</Menu>
<span class="text-text-tertiary text-sm">Last action: <code>{lastAction}</code></span>
</div>
Icon-only trigger
—<script lang="ts">
import { Button, Menu, type MenuObjectOption, MoreHorizontalIcon } from '@urbicon-ui/blocks';
let lastAction = $state('—');
const items: MenuObjectOption[] = [
{ label: 'Rename', onSelect: () => (lastAction = 'Rename') },
{ label: 'Duplicate', onSelect: () => (lastAction = 'Duplicate') },
{ label: 'Delete', onSelect: () => (lastAction = 'Delete') }
];
</script>
<div class="flex items-center gap-4">
<Menu {items}>
{#snippet customTrigger(toggle, open)}
<Button
variant="ghost"
size="sm"
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={open}
onclick={toggle}
>
<MoreHorizontalIcon class="h-4 w-4" />
</Button>
{/snippet}
</Menu>
<span class="text-text-tertiary text-sm">Last action: <code>{lastAction}</code></span>
</div>
Account menu
—<script lang="ts">
import { Menu, Button, type MenuObjectOption } from '@urbicon-ui/blocks';
let lastAction = $state('—');
const items: MenuObjectOption[] = [
{ label: 'Profile', onSelect: () => (lastAction = 'Profile') },
{ label: 'Billing', onSelect: () => (lastAction = 'Billing') },
{ label: 'Team', onSelect: () => (lastAction = 'Team') }
];
</script>
<div class="flex items-center gap-4">
<Menu placeholder="Account" {items}>
{#snippet customHeader()}
<div class="text-text-secondary text-xs font-medium">Logged in as jane@example.com</div>
{/snippet}
{#snippet customFooter()}
<div class="flex justify-end">
<Button variant="ghost" intent="danger" size="sm" onclick={() => (lastAction = 'Sign out')}>
Sign out
</Button>
</div>
{/snippet}
</Menu>
<span class="text-text-tertiary text-sm">Last action: <code>{lastAction}</code></span>
</div>
Selectable settings
Name<script lang="ts">
import { Menu, type MenuObjectOption } from '@urbicon-ui/blocks';
let sortBy = $state('Name');
// Menu displays the checked state but never stores it — `sortBy` here is
// the single source of truth, updated by each item's onSelect.
const sortOption = (label: string): MenuObjectOption => ({
id: label.toLowerCase(),
label,
checked: sortBy === label,
onSelect: () => (sortBy = label)
});
const items = $derived<MenuObjectOption[]>([
{
id: 'sort',
label: 'Sort by',
detail: sortBy,
children: [sortOption('Name'), sortOption('Date'), sortOption('Size')]
},
{ id: 'refresh', label: 'Refresh' }
]);
</script>
<div class="flex items-center gap-4">
<Menu placeholder="View" {items} />
<span class="text-text-tertiary text-sm">Sorted by <code>{sortBy}</code></span>
</div>
03 Customization
Primary-accented panel via slotClasses
<Menu
placeholder="Actions"
items={['Rename', 'Duplicate', 'Archive']}
slotClasses={{
content: 'border-primary/30 shadow-[var(--blocks-shadow-lg)]',
item: 'hover:bg-primary/10 hover:text-primary'
}}
/>This is one of five ways to restyle a block. See Customization for class, slotClasses, unstyled, preset and provider-level overrides.
04 Accessibility
Built-in ARIA
Uses role="menu" on the panel and role="menuitem" on each item, with aria-haspopup="menu" + aria-expanded on the trigger. An item given checked renders as role="menuitemradio" with aria-checked, so the active setting is announced, not
just marked. Sub-menus add aria-haspopup="menu" on the submenu trigger. A
section renders its header as role="presentation" and wraps the items it names in a role="group" labelled by that header, so a radio set
is announced with the group it belongs to; role="separator" is reserved for the divider.
Keyboard
Enter / Space on the trigger to open. Arrow keys move focus between items (roving tabindex), Home / End jump to the first/last item, and Tab moves focus out and closes the menu (W3C menu pattern). Enter / Space activates an item. Escape closes the menu and restores focus to the trigger.
Focus Management
On activation the menu closes and focus returns to the trigger. Items with keepOpen dispatch their action without closing. Useful
for repeated actions like "Add tag".
05 API Reference
40 propsProp | Type | Default | Description | |
|---|---|---|---|---|
chevronAnimation inherited | rotatetranslatefadenone | — | chevronAnimation property | |
children inherited | Snippet | — | Declarative children mode — use <MenuItem> / <MenuDivider> /
<MenuSection>. A <MenuSection> wraps the items it names, the same way
a { type: 'section' } entry owns the items that follow it. | |
class | string | — | class property | |
contextTrigger inherited | Snippet | — | Turn the menu into a **context menu**: instead of a trigger button, the
snippet you pass becomes a right-click target. A contextmenu (right-click
or long-press) on it opens the menu at the cursor position — the native
browser context menu is suppressed. Keyboard navigation, dismissal and
item selection behave exactly as in the dropdown menu; on dismiss, focus
returns to wherever it was. Mutually exclusive with customTrigger/the
default trigger button (when set, no trigger button renders). | |
customFooter inherited | Snippet | — | Optional custom footer rendered below the items list. | |
customHeader inherited | Snippet | — | Optional custom header rendered above the items list. | |
customItem inherited | Snippet<[TItem]> | — | Custom per-item content. **Render visible content only** — the outer
role="menuitem" button is provided by Menu and handles the click /
keyboard activation. Putting an interactive element (<button>, <a>)
inside the snippet creates nested-interactive HTML and triggers the
item's action twice via event bubbling.
Positional arg: (item). To dispatch from outside the normal click
(e.g. a "Recent" entry that needs to activate from a parent shortcut),
call the item's own onSelect directly. | |
customTrigger inherited | Snippet<[() => void, boolean, () => void]> | — | Replace the default trigger button (chevron + label) with a custom element.
Positional args: (toggle, open, dismiss).
- toggle: flips the open state — wire this to your custom trigger's
click handler so the menu can be opened from the consumer's element.
- open: current open state — useful for aria-expanded.
- dismiss: closes the menu without changing toggle history; rarely
needed but provided for symmetry.
The consumer's element should bind its onclick to toggle and set
aria-expanded={open} + aria-haspopup="menu" for ARIA correctness. | |
disabled variant | true | — | Controls the disabled behavior and appearance of the Menu component. Available options: true. | |
getItemChecked inherited | (item: TItem) => boolean | undefined | — | Optional checked-state resolver for items in array mode (undefined = plain action item). | |
getItemChildren inherited | (item: TItem) => TItem[] | undefined | — | getItemChildren property | |
getItemClass inherited | (item: TItem) => string | undefined | — | Optional per-item class resolver for items in array mode. | |
getItemDetail inherited | (item: TItem) => string | undefined | — | Optional right-aligned detail-text resolver for items in array mode. | |
getItemDisabled inherited | (item: TItem) => boolean | — | getItemDisabled property | |
getItemIcon inherited | (item: TItem) => unknown | — | Optional icon resolver for items in array mode. | |
getItemId inherited | (item: TItem) => string | — | getItemId property | |
getItemLabel inherited | (item: TItem) => string | — | Optional mapping functions for custom item shapes. | |
getSectionLabel inherited | (item: MenuSectionHeader) => string | — | Section label override. Accepts concrete section header type. | |
id | string | — | id property | |
intent inherited | ButtonVariants['intent'] | 'neutral' | Button intent applied to the default trigger button. | |
isDivider inherited | (item: MenuItemType) => boolean | — | Divider detection override, symmetric to isSection. Supply it when your
own item shape carries a type: 'divider' field of its own meaning — the
built-in check is structural, and without this mapper such a row would
render as a rule and lose its onSelect.
This mapper and isSection *replace* the built-in check rather than
narrowing it, so the escape is all-or-nothing: isDivider={() => false}
also switches off genuine { type: 'divider' } entries, which render as
plain rows again — express those in your own shape instead. | |
isSection inherited | (item: MenuItemType) => boolean | — | Section detection override. Applies to full union, not just TItem. | |
items inherited | TItem[] | — | Array of menu items. Each item's onSelect runs when activated. | |
itemSize variant | lgmdsm | md | Controls the itemSize behavior and appearance of the Menu component. Available options: lg, md, sm. | |
loading inherited | boolean | false | Whether the default trigger button is in loading state. | |
mint inherited | MintProp | 'none' | Micro-interaction preset forwarded to the inner default trigger Button and applied to each menu item row (per-item via context). Only applies while not disabled. | |
onOpenChange inherited | (open: boolean) => void | — | Fires when the menu opens or closes from user interaction (trigger
click, item activation, Escape, Tab-out, outside click). Receives the
new open state. Not called when the consumer writes bind:open directly. | |
open variant | true | false | Controls the open behavior and appearance of the Menu component. Available options: true. | |
placeholder inherited | string | — | Placeholder text shown on the default trigger. Acts as the trigger's
accessible name unless aria-label is supplied. Typical values: "Actions",
"More", "Options". Ignored when customTrigger is provided. | |
placement variant | bottombottom-endbottom-starttop +2 more | bottom-start | Controls the positioning and alignment of the Menu relative to its container or trigger element. Available options: bottom, bottom-end, bottom-start, and 3 more. | |
preset inherited | string | — | Apply a named preset registered via <BlocksProvider presets={{ Menu: {...} }}>.
Prefer this over class overrides when the requested look falls outside the
semantic intent palette — presets keep hover/active/dark-mode logic coherent
and make the custom look reusable across the project. | |
size inherited | ButtonVariants['size'] | 'md' | Button size applied to the default trigger button. | |
slotClasses inherited | Partial<Record<MenuSlots, string>> | — | Per-slot class overrides merged with the variant styles. Slots: base | trigger | triggerText | chevron | content | header | section | group | divider | items | item | indicator | detail | submenu | footer | |
syncWidth variant | falsetrue | true | Controls the syncWidth behavior and appearance of the Menu component. Available options: false, true. | |
tier variant | commitmodify | commit | Selects the semantic radius tier of the Menu — the shape family it belongs to (--radius-commit/-modify/-contain/-bridge). Shape is retuned per family in your theme, so this picks the family rather than a pixel value. Available options: commit, modify. | |
unstyled inherited | boolean | false | Remove the default variant classes, the default trigger Button's included. Only user-supplied classes apply. | |
usePortal variant | falsetrue | true | Controls the usePortal behavior and appearance of the Menu component. Available options: false, true. | |
variant inherited | ButtonVariants['variant'] | 'outlined' | Button variant applied to the default trigger button. | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'children' | 'class' | 'placeholder') | |
...MenuVariants variant | VariantProps | — | Styling variants from MenuVariants |
06 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
MenuSpecificProps | interface | props | 0 | Menu-specific props. The catalog JSDoc (@description/@tag/@related)
lives on MenuProps below — the interface docs-gen actually picks up —
so it is not duplicated here. | |
MenuProps | interface | props | 0 | — | |
MenuOption | type | helper | 0 | Shorthand item form: the string is the label and serves as the item's
stable id (for sub-menu bookkeeping). Use MenuObjectOption to attach
an onSelect callback or other rich item state. | |
MenuSectionHeader | interface | helper | 0 | Section header item for grouping related menu options. Renders the header
plus a role="group" around the items that follow it, up to the next header. | |
MenuDividerItem | interface | helper | 0 | Rule between two runs of items — the items-array equivalent of
<MenuDivider />, which the array shape previously had no way to express
(a { type: 'divider' } entry rendered as a nameless role="menuitem").
Renders where it is written, inside the section it falls in. | |
MenuObjectOption | interface | helper | 0 | Menu item with explicit label, action callback, and optional nested
children. Menu items are *verbs* — the id is only an internal stable
identifier for sub-menu bookkeeping and DOM id derivation, not a
selectable value. An item given checked additionally *shows* a setting
(role="menuitemradio"), but the state stays consumer-owned: Menu displays
it and never stores a selection. (For committing a value to a form use
Select.) | |
MenuItemType | type | helper | 0 | Union of all supported menu item shapes. - string: simple item label (value equals label) - MenuObjectOption: rich item with label/onSelect and optional children - MenuSectionHeader: non-selectable header grouping the following items - MenuDividerItem: a rule between two runs of items | |
MenuCustomSlots | interface | variant | 0 | — | |
MenuVariants | type | variant | 1 | — | |
MenuIconVariants | type | variant | 0 | — | |
MenuSlots | type | variant | 0 | Slot names derived from the tv() config — single source of truth for slotClasses. | |
MintProp | type | helper | 1 | — | |
ButtonVariants | type | variant | 0 | — | |
AnimationProps | interface | props | 0 | — | |
Placement | type | helper | 0 | — | |
InteractiveTier | type | helper | 0 | Semantic radius tier for interactive surfaces (3-tier system).
- commit → r-human (CTA, identity, status declarations)
- modify → r-interactive (fields, navigation, secondary actions)
Container components (Card, Alert, Toolbar surface, …) live in a third
tier contain (r-structure) which is **not** part of this propagation
context — those surfaces are always r-structure by design and have no
tier-flip use case. | |
MintName | type | helper | 0 | A mint name: a built-in (autocompleted), 'none' to disable, or any
consumer-registered name. (string & {}) keeps the registry open — a
custom name still type-checks, it just isn't suggested. A typo therefore
also still compiles (it resolves like an unregistered custom name and
warns at runtime); the union buys completion and docs, not validation. | |
MintConfig | interface | helper | 0 | — | |
VariantProps | type | helper | 1 | — | |
Side | type | helper | 0 | — | |
Alignment | type | helper | 0 | — | |
BuiltinMintName | type | helper | 0 | Built-in mint names as a literal union, so the mint prop autocompletes
across every component — the single list the hand-curated playground knobs
and docs used to drift away from. |
07 Installation
Import
import { Menu, MenuItem, MenuDivider, MenuSection } from '@urbicon-ui/blocks';