Skip to content
omaris

Navigation

Menu

MD3 menu — a list of actions hanging off a trigger.

import { Menu } from 'omaris'
Learn

Examples

Basic

The trigger snippet gets the props that wire it to the menu. The overflow on a row or a card, where three buttons would crowd it.

<script lang="ts">	import { Button, Menu, MenuItem } from 'omaris';</script>​<Menu label="Project actions">	{#snippet trigger(props)}		<Button variant="outlined" {...props}>Actions</Button>	{/snippet}​	<MenuItem>Rename</MenuItem>	<MenuItem>Duplicate</MenuItem>	<MenuItem tone="destructive">Delete</MenuItem></Menu>

Icons and shortcuts

Icons and shortcuts earn their place on a menu people open often; inset lines up a row without an icon beside the ones that have one.

<script lang="ts">	import { IconButton, Menu, MenuItem, MenuLabel } from 'omaris';</script>​<Menu label="Edit">	{#snippet trigger(props)}		<IconButton aria-label="Edit menu" {...props}>			<svg viewBox="0 0 24 24" fill="currentColor">				<circle cx="12" cy="5" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle					cx="12"					cy="19"					r="1.6"				/>			</svg>		</IconButton>	{/snippet}​	<MenuLabel>Edit</MenuLabel>	<MenuItem shortcut="⌘C">		{#snippet icon()}			...		{/snippet}		Copy	</MenuItem>	<MenuItem shortcut="⌘V">		{#snippet icon()}			...		{/snippet}		Paste	</MenuItem>	<MenuItem inset shortcut="⌘⌫" tone="destructive">Delete</MenuItem></Menu>

Selection

selected draws the tick. selected={false} is meaningfully different from leaving it off: it reserves the space, so a list of choices does not shuffle sideways when one is picked. radio makes each row one of a set, which is what a screen reader announces.

<script lang="ts">	import { Button, Menu, MenuItem } from 'omaris';​	let sort = $state('Newest first');</script>​<Menu label="Sort by" closeOnSelect>	{#snippet trigger(props)}		<Button variant="tonal" {...props}>Sort: {sort}</Button>	{/snippet}​	<MenuItem radio selected={sort === 'Newest first'} onclick={() => (sort = 'Newest first')}>		Newest first	</MenuItem>	<MenuItem radio selected={sort === 'Oldest first'} onclick={() => (sort = 'Oldest first')}>		Oldest first	</MenuItem>	<MenuItem radio selected={sort === 'Largest total'} onclick={() => (sort = 'Largest total')}>		Largest total	</MenuItem></Menu>

Submenus

MenuSub is a row that opens a menu of its own — on hover, a press or →. On a phone the menu is a bottom sheet and the submenu expands in place.

<script lang="ts">	import { Button, Divider, Menu, MenuItem, MenuSub } from 'omaris';​	let label = $state('None');</script>​<Menu label="Conversation">	{#snippet trigger(props)}		<Button variant="tonal" {...props}>Conversation</Button>	{/snippet}​	<MenuItem shortcut="E">Archive</MenuItem>	<MenuItem shortcut="M">Mute</MenuItem>	<MenuSub label="Label: {label}">		{#each ['None', 'Work', 'Family', 'Receipts'] as option (option)}			<MenuItem radio selected={label === option} onclick={() => (label = option)}>				{option}			</MenuItem>		{/each}	</MenuSub>	<Divider class="my-1" />	<MenuItem tone="destructive">Delete</MenuItem></Menu>

Placement

side and align place it, and it flips itself when there is no room. align="end" for a menu hanging off the far end of a toolbar.

<script lang="ts">	import { Button, Menu, MenuItem } from 'omaris';</script>​<Menu side="top" align="start" label="Upwards">	{#snippet trigger(props)}		<Button variant="outlined" size="xs" {...props}>Top</Button>	{/snippet}	<MenuItem>Opens above</MenuItem></Menu>​<Menu side="right" align="start" label="Sideways">	{#snippet trigger(props)}		<Button variant="outlined" size="xs" {...props}>Right</Button>	{/snippet}	<MenuItem>Opens beside</MenuItem></Menu>​<Menu matchWidth label="Matched">	{#snippet trigger(props)}		<Button variant="outlined" size="xs" class="w-48 max-w-full" {...props}>matchWidth</Button>	{/snippet}	<MenuItem>As wide as the trigger</MenuItem></Menu>

When to use it

Use it for

  • Actions off a trigger: the overflow on a row or card behind an Icon Button, the account menu, "more" at the end of a toolbar.
  • A single choice the trigger names, like "Density: comfortable". selected draws the tick on the current row, and radio makes the rows one set.
  • A second level that would crowd the first: "Move to ▸", "Sort by ▸" as a MenuSub. On a phone it expands in place inside the sheet.
  • A set of toggles changed in one visit. closeOnSelect={false} keeps it open; selected={false} reserves the tick's space so rows do not shift.
  • Links, when a row has href.

Not for

  • A value in a form → Select; one you search for → Combobox. A Menu does something; a Select holds something.
  • The main action and its alternatives → Split Button.
  • Moving between the app's sections → Navigation Drawer.
  • Anything with a field in it, or a decision to confirm → Dialog or Sheet.
  • Explaining what a control does → Tooltip.

Do

  • Spread the trigger snippet's props onto the real button, so the ARIA and the click land on the element that has focus.
  • Put the destructive row last, after a Divider, with tone="destructive".
  • Give every row an icon or none. inset lines up the odd one out.
  • Use shortcut only for a shortcut that works on the page.

Don't

  • Nest one menu in another; there is no submenu. Group with MenuLabel and a Divider, or move the set into a Sheet.
  • Set selected on a row that is an action. A tick means a state, and "Delete ✓" makes no sense.
  • Build a row without data-menu-item. The arrow keys find rows by it; MenuItem sets it, your own <button> does not.
  • Let it grow past a dozen rows. Past that it scrolls, and a scrolled menu should be a page.

Quick reference

size Menu
  • sm
  • md (default)
  • lg
tone MenuItem
  • default (default)
  • destructive

API

Menu

MD3 menu — a list of actions hanging off a trigger.

The trigger snippet is handed the ARIA and the click handler to spread onto whatever opens the menu, so the attributes land on the real button rather than a wrapper. The surface is portalled and anchored, so it escapes scroll containers and flips when it runs out of room.

Keyboard: Enter/Space/↓ open it, ↑↓ move, Home/End jump, typing jumps to a matching item, Escape closes and hands focus back to the trigger.

import { Menu } from 'omaris'

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

surface
No description in the source yet.
trigger
The wrapper around whatever opens the menu — w-full, usually.

Props

open bindable

Defaults to false

boolean

Bindable.

side

Defaults to 'bottom'

AnchorSide
align

Defaults to 'start'

AnchorAlign
offset

Defaults to 6

number
size

Defaults to 'md'

MenuSize
sm
md
lg
matchWidth

Defaults to false

boolean

Make the menu at least as wide as its trigger.

closeOnSelect

Defaults to true

boolean

Leave the menu open after an item is chosen — for a set of toggles.

label
string

Accessible name for the menu.

mobileSheet

Defaults to true

boolean

On a phone, rise from the bottom edge as a sheet — full width, a scrim behind, dragged down to dismiss, rows a thumb can hit — instead of hanging off the trigger. false keeps it anchored.

class
string
classes
MenuClasses

Per-part Tailwind overrides. class still covers the root.

trigger
Snippet<[MenuTriggerProps]>

Receives the props to spread onto the opening control.

children
Snippet

The items — MenuItem, MenuLabel, Divider. Arrow-key navigation finds rows by their data-menu-item attribute, which MenuItem sets for you; an item you build yourself needs that attribute or the keyboard will skip it.

aria-haspopup required
'menu'
aria-expanded required
boolean
aria-controls required
string | undefined
onclick required
(event: MouseEvent) => void
onkeydown required
(event: KeyboardEvent) => void

MenuItem

A row in a Menu.

Renders as a link when given href, otherwise a button. selected gives you the checkable variant (MD3's menu with a leading tick); tone="destructive" is the delete row.

import { MenuItem } from 'omaris'

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
No description in the source yet.
label
The label. Truncates by default; override to let it wrap.
shortcut
The right-aligned shortcut hint.
check
The tick drawn while selected.

Props

tone

Defaults to 'default'

MenuItemTone
default
destructive
selected
boolean

Checkable row: shows a trailing tick when true. Leaving it undefined keeps the row a plain menuitem rather than a checkbox.

radio

Defaults to false

boolean

One of a set, rather than a toggle of its own: the checkable row becomes a menuitemradio, which is what a screen reader needs to say "Sort by: Name, selected, 1 of 3".

inset

Defaults to false

boolean

Indent to line up with rows that have a leading icon.

shortcut
string

Right-aligned hint — a keyboard shortcut, a count.

ripple

Defaults to true

boolean
class
string
classes
MenuItemClasses

Per-part Tailwind overrides. class still covers the root.

icon
Snippet

Leading icon.

end
Snippet

Trailing content, before the tick.

children
Snippet
variant
ButtonVariant

Fill style. MD3's five, plus link for inline navigation.

size
ButtonSize

MD3 Expressive heights: 32 / 40 / 56 / 96 / 136dp.

shape
ButtonShape

Pill (round) or MD3 rounded rectangle (square).

confetti
boolean | ConfettiOptions

Confetti on press. true for the default burst, or any ConfettiOptions — confetti={{ preset: 'fireworks' }}.

loading
boolean

Grows a spinner in at the start of the button and blocks interaction. The label stays put; only an icon button swaps its icon out.

toggle
boolean

Turns the button into a two-state toggle driven by pressed. Ignored when href is set — a link has no pressed state.

pressed bindable
boolean

Selected state of a toggle button. Bindable.

MenuLabel

A heading over a run of menu items.

Presentational only — it is never in the focus order and screen readers skip past it to the items themselves.

import { MenuLabel } from 'omaris'

Props

class
string
children
Snippet

MenuSub

A row that opens a menu of its own — "Move to ▸", "Sort by ▸".

On a desktop the submenu hangs off the row's far edge: it opens on hover after a short intent delay, on a press, or on → (← in a right-to-left document), and ← or Escape comes back out to the row.

On a phone the parent menu is a bottom sheet, and a second sheet on top of it — or a panel hanging off the side of a full-width one — would be worse than no submenu at all. There the row expands in place instead, its items indented under it, the way a native settings list does.

import { MenuSub } from 'omaris'

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

trigger
The row that opens it. Styled by MenuItem; this adds the chevron's room.
chevron
No description in the source yet.
surface
The submenu's panel, when it hangs off the side.
inline
The in-place list, when the menu is a sheet.

Props

label required
string

The row's text.

open bindable

Defaults to false

boolean

Bindable.

delay

Defaults to 120

number

Delay before a hover opens it, in ms — long enough to cross it on the way down.

class
string
classes
MenuSubClasses
icon
Snippet

Leading icon on the row.

children
Snippet

The submenu's rows — MenuItem, MenuLabel, Divider, another MenuSub.