Menu

A menu is a <details> with a styled panel. That is a deliberate choice over the popover API: a popover lives in the top layer and cannot be positioned against its trigger without anchor positioning, which is not yet everywhere. A <details> positions itself correctly today and needs nothing loaded.

The trigger is that <summary>, styled as a button — so you pass the label and the button props to menu() rather than passing a rendered button(). A summary is already interactive, and a button inside one nests two controls where there is a single action: invalid markup, and two tab stops for one thing.

Close-on-outside-click and Escape come from an ontoggle handler that imports them the first time a menu is opened — and only then. If that module never arrives, a menu still opens and closes from its own summary.

Basic menu

menu({ trigger: 'Actions' },
  menuItem({ href: '#edit' }, 'Edit'),
  menuItem({ href: '#duplicate' }, 'Duplicate'),
  menuSeparator(),
  menuItem({ href: '#delete' }, 'Delete'),
)

Alignment

A menu opens from the start edge of its trigger. align: 'end' flips it, which is what a menu near the right edge of a bar needs.

Start-aligned
End-aligned
stack({ direction: 'row', gap: 'xl', justify: 'space-between', style: 'width: 100%' },
  menu({ trigger: 'Start-aligned', variant: 'soft' },
    menuItem({ href: '#a' }, 'First'),
    menuItem({ href: '#b' }, 'Second'),
  ),
  menu({ trigger: 'End-aligned', variant: 'soft', align: 'end' },
    menuItem({ href: '#c' }, 'First'),
    menuItem({ href: '#d' }, 'Second'),
  ),
)

Icon triggers

An icon with no trigger text needs a label — it becomes the accessible name the icon cannot provide.

stack({ direction: 'row', gap: 'md' },
  menu({
    align: 'end',
    label: 'More actions',
    variant: 'ghost',
    icon: icon('more-horizontal'),
  },
    menuItem({ href: '#rename' }, 'Rename'),
    menuItem({ href: '#move' }, 'Move'),
    menuSeparator(),
    menuItem({ href: '#archive' }, 'Archive'),
  ),
)

Items with icons

menu({ trigger: 'File' },
  menuItem({
    href: '#new',
    icon: icon('plus'),
  }, 'New page'),
  menuItem({
    href: '#open',
    icon: icon('folder'),
  }, 'Open…'),
  menuSeparator(),
  menuItem({
    href: '#build',
    icon: icon('zap'),
  }, 'Build site'),
)

An item with no href renders a <button> — for an action that happens on the page rather than a navigation.

Export
menu({ trigger: 'Export', variant: 'soft', color: 'primary' },
  menuItem({ onclick: "window.siteloUiToast && window.siteloUiToast('Exported as JSON.', 'success')" }, 'As JSON'),
  menuItem({ onclick: "window.siteloUiToast && window.siteloUiToast('Exported as CSV.', 'success')" }, 'As CSV'),
)

In an app bar

appBar({ brand: 'sitelo' },
  appBarSpacer(),
  appBarActions(
    themeToggle(),
    menu({
      align: 'end',
      label: 'More',
      variant: 'ghost',
      icon: icon('more-horizontal'),
    },
      menuItem({ href: '/docs' }, 'Docs'),
      menuItem({ href: '/examples' }, 'Examples'),
      menuSeparator(),
      menuItem({ href: 'https://github.com/paul-browne/sitelo' }, 'GitHub'),
    ),
  ),
)

Accessibility

The panel is a role="menu" whose items are role="menuitem", and the summary carries aria-haspopup. A <details> is not a native menu widget, so this is a reasonable approximation rather than a perfect one — for a plain list of links, a nav inside the details is just as valid and claims less.

Props

menu() — the trigger props are the button ones:

PropTypeDefaultDescription
triggerChildVisible label. Pass text, not a rendered button().
iconChildMarkup before the label, or on its own for an icon-only trigger.
labelstringAccessible name. Required when there is an icon and no trigger text.
variant'solid' | 'soft' | 'outline' | 'ghost' | 'link''outline'Trigger styling.
color'primary' | 'neutral' | 'success' | 'warning' | 'danger''neutral'Which palette the trigger draws from.
size'sm' | 'md' | 'lg''md'Trigger size.
align'start' | 'end''start'Which edge of the trigger the panel lines up with.
triggerClassstringExtra classes for the trigger rather than the wrapping details.

menuItem():

PropTypeDefaultDescription
hrefstringRenders an anchor; without it, a button.
iconChildMarkup before the label.
asstring'button'Element to render when there is no href.

menuSeparator() takes no props — it is the hairline between groups of items.