Menus

A menu shows a list of choices on a temporary surface, anchored to the control that opened it. It is a native popover, so it lives in the top layer and is not clipped by a scroll container or an overflow: hidden ancestor, and the browser closes it when you click outside or press Escape.

Two things connect a menu to its anchor. command="toggle-popover" and commandfor on the trigger open it, with no script involved, and CSS anchor positioning places it: give the trigger an anchor-name and point the menu at it with position-anchor. Pass the same element as the anchor prop so the menu can size itself to the space that is actually left on screen.

Usage

<script lang="ts">
	import { Button, Menu, MenuItem } from 'noph-ui'
	let menuBtn: HTMLElement | undefined = $state()
</script>

<Button
	style="anchor-name:--city-menu"
	command="toggle-popover"
	commandfor="browser-menu"
	bind:element={menuBtn}
>
	Open menu
</Button>
<Menu anchor={menuBtn} id="browser-menu" style="position-anchor:--city-menu;max-width: 300px">
	<MenuItem>New York</MenuItem>
	<MenuItem>Los Angeles</MenuItem>
	<MenuItem>Berlin</MenuItem>
	<MenuItem>London</MenuItem>
</Menu>

Methods

Where a trigger cannot carry commandfor, open and close the menu imperatively instead. Bind a reference with bind:this and type it with ReturnType<typeof Menu>; it is undefined until the component has mounted, so call through ?.. Prefer the attributes where you have the choice, and leave bind:open to report the state rather than to set it.

<script lang="ts">
	let menu: ReturnType<typeof Menu> | undefined = $state()
</script>

<Menu bind:this={menu} anchor={menuBtn}>
	<MenuItem>New York</MenuItem>
</Menu>
<Button onclick={() => menu?.show()}>Open menu</Button>
MethodTypeDescription
show() => voidOpens the menu.
close() => voidCloses the menu.

Items

MenuItem is a list item with role="menuitem". It takes the same start, end, supportingText, selected and disabled attributes as ListItem. Pass an href and the item renders as a link instead of a button. A Divider between items gets its spacing automatically.

<Menu anchor={menuBtn} id="account-menu">
	<MenuItem>
		Profile
		{#snippet start()}<Icon>person</Icon>{/snippet}
	</MenuItem>
	<MenuItem href="/settings">Settings</MenuItem>
	<Divider />
	<MenuItem disabled>Sign out</MenuItem>
</Menu>

Placement

A menu opens on the side of the anchor you asked for, below it by default, and moves out of the way on its own when that side is too small: it flips to the opposite side when the menu would not fit, and slides along the inline axis when it would run off the edge of the window. When neither side has room, the menu takes the full height of the window and sits over the anchor instead of squeezing into whichever gap is bigger. Only a menu taller than the window stays on the roomier side and scrolls, since covering the anchor would not help there.

The anchor prop is what makes this work: it is the element the menu measures the room against. Without it the menu still opens, but at whatever height its content asks for.

Set coverAnchor to false where the anchor has to stay in sight, and the menu skips that last step: it keeps to the roomier side and scrolls there instead. AutoComplete does this, because a list over the field would hide what you are typing.

Accessibility

The container renders role="menu" and every item role="menuitem". The items share a single tab stop: Tab moves into and out of the menu as a whole, while and move between the items and wrap around at the ends. Home and End jump to the first and the last item. Disabled items are skipped.

Because the menu is a popover, Escape closes it and focus returns to the trigger without any code of your own. Give the trigger an accessible name that says what the menu is for, not just “Open menu”.

Theming

TokenDefault value
--np-menu-container-color--np-color-surface-container
--np-menu-text-color--np-color-on-surface
--np-menu-container-shape--np-shape-corner-extra-small
--np-menu-margin2px
--np-menu-position-areabottom
--np-menu-justify-selfanchor-center
--np-menu-over-anchor-position-areaspan-all

--np-menu-position-area takes any CSS position-area value and decides which side of the anchor the menu opens on. Whatever you pick, the menu still moves as Placement describes when that side is too small. --np-menu-over-anchor-position-area is the area used for that last fallback, when the menu spans the full height over the anchor. Keep span-all in the block axis and repeat the inline half of --np-menu-position-area, so the menu stays lined up the same way. The items themselves are styled through the list tokens.

Example

<Menu
	anchor={menuBtn}
	id="themed-menu"
	--np-menu-container-color="var(--np-color-surface-container-highest)"
	--np-menu-container-shape="1rem"
	--np-menu-position-area="top"
>
	<MenuItem>New York</MenuItem>
</Menu>

API

Everything else you pass is forwarded to the menu element, so id, class, style and the usual event handlers work as expected.

AttributeTypeDefaultDescription
anchorHTMLElement | undefinedundefinedThe element the menu belongs to. Room on screen is measured against it, which decides how tall the menu may be and which way it moves when a side is too small.
openboolean | undefinedundefinedBindable. Reflects whether the menu is currently open.
coverAnchorbooleantrueWhether the menu may sit over its anchor when neither side of it is tall enough. With false the menu stays on the roomier side and scrolls there.
popover'auto' | 'manual' | null'auto'Popover behaviour. auto closes on an outside click and on Escape; use manual when you want to control that yourself.
elementHTMLDivElement | undefinedundefinedBindable reference to the menu element.

MenuItem accepts the ListItem attributes apart from variant and softFocus, which the menu sets itself.