FairGarden Design

Menu

Actions on the current object, sort orders and view options, in an anchored list opened by a Button.

import {
  Menu,
  MenuCheckboxItem,
  MenuGroup,
  MenuGroupLabel,
  MenuItem,
  MenuPopup,
  MenuRadioGroup,
  MenuRadioItem,
  MenuSeparator,
  MenuSubmenu,
  MenuSubmenuTrigger,
  MenuTrigger,
} from '@fairgarden-private/design/components/Menu'

The trigger is a Button: an outline or text Button with a title-case label ("Sort By"), or an icon-only "…". The popup renders in a portal as a white scope that follows the page mode, whatever the trigger's ground, with a --border-size-2 --primary12 edge. It sits 8 px from the trigger, aligned to its start edge, 200 to 320 px wide and at least as wide as the trigger. Rows are 44 px at every width, with a leading slot, a sentence-case label and an optional shortcut. It opens instantly, or with a clip reveal from the trigger side, and is hidden in print; the state it sets prints as a caption.

Use a menu for actions on the current object (more, sort, export, share, row actions), not for page navigation or form values.

Action menu

Action menu

Highlight, by pointer or keyboard, is a soft fill plus a --ds-stroke-3 start-edge bar; the bar alone survives forced colors. A destructive item shows the danger glyph ◆ and names the verb and object, so color is never the only cue. A disabled item is muted, takes no highlight and says why where it can. Submenus fly out to the end side.

Field note, 12 May

No action yet.

MenuActions.tsx
'use client'

import * as React from 'react'
import {
  Menu,
  MenuGroup,
  MenuGroupLabel,
  MenuItem,
  MenuPopup,
  MenuSeparator,
  MenuSubmenu,
  MenuSubmenuTrigger,
  MenuTrigger,
} from '@fairgarden-private/design/components/Menu'
import styles from './actions.module.css'

export function MenuActions() {
  const [last, setLast] = React.useState('No action yet.')
  const run = (label: string) => () => setLast(`${label}.`)

  return (
    <div className={styles.stack}>
      <div className={styles.row}>
        <span className={styles.object}>Field note, 12 May</span>
        <Menu>
          <MenuTrigger iconOnly icon="more_horiz">
            Note Actions
          </MenuTrigger>
          <MenuPopup>
            <MenuItem icon="download" shortcut="⌘ D" onClick={run('Downloaded the note')}>
              Download note
            </MenuItem>
            <MenuItem icon="search" shortcut="⌘ F" onClick={run('Searched similar notes')}>
              Find similar notes
            </MenuItem>
            <MenuSubmenu>
              <MenuSubmenuTrigger icon="arrow_forward">Move to</MenuSubmenuTrigger>
              <MenuPopup>
                <MenuItem onClick={run('Moved to Spring count')}>Spring count</MenuItem>
                <MenuItem onClick={run('Moved to Meadow survey')}>Meadow survey</MenuItem>
                <MenuItem disabled>Archive (needs 1 photo)</MenuItem>
              </MenuPopup>
            </MenuSubmenu>
            <MenuSeparator />
            <MenuGroup>
              <MenuGroupLabel>Danger zone</MenuGroupLabel>
              <MenuItem destructive onClick={run('Deleted the note')}>
                Delete note
              </MenuItem>
            </MenuGroup>
          </MenuPopup>
        </Menu>
      </div>
      <p className={styles.status} aria-live="polite">
        {last}
      </p>
    </div>
  )
}

Sort and options, on forest

Sort and options, on forest

A checked radio item shows a leading ●, and a checked checkbox item a leading ✓, in --primary12 with the label at --font-weight-6: no fill and no bar. The sort trigger shows the current value. On the forest ground the trigger takes forest's roles, while the popup keeps its white scope.

The trigger follows the forest ground; the popup stays a white scope in the page mode.

View: sorted by distance · dog-friendly only

MenuOptions.tsx
'use client'

import * as React from 'react'
import { Ground } from '@fairgarden-private/design/components/Ground'
import {
  Menu,
  MenuCheckboxItem,
  MenuGroup,
  MenuGroupLabel,
  MenuPopup,
  MenuRadioGroup,
  MenuRadioItem,
  MenuSeparator,
  MenuTrigger,
} from '@fairgarden-private/design/components/Menu'
import styles from './options.module.css'

const orders = {
  distance: 'Distance',
  climb: 'Climb',
  name: 'Name',
} as const

type Order = keyof typeof orders

export function MenuOptions() {
  const [order, setOrder] = React.useState<Order>('distance')
  const [showClosed, setShowClosed] = React.useState(false)
  const [dogFriendly, setDogFriendly] = React.useState(true)

  return (
    <Ground preset="forest" kind="field" className={styles.face}>
      <p className={styles.caption}>
        The trigger follows the forest ground; the popup stays a white scope in the page mode.
      </p>
      <Menu>
        <MenuTrigger variant="outline" icon="expand_more" iconPosition="end">
          {`Sort: ${orders[order]}`}
        </MenuTrigger>
        <MenuPopup>
          <MenuGroup>
            <MenuGroupLabel>Sort by</MenuGroupLabel>
            <MenuRadioGroup value={order} onValueChange={(value: Order) => setOrder(value)}>
              {(Object.keys(orders) as Order[]).map((key) => (
                <MenuRadioItem key={key} value={key}>
                  {orders[key]}
                </MenuRadioItem>
              ))}
            </MenuRadioGroup>
          </MenuGroup>
          <MenuSeparator />
          <MenuGroup>
            <MenuGroupLabel>Show</MenuGroupLabel>
            <MenuCheckboxItem checked={showClosed} onCheckedChange={setShowClosed}>
              Closed trails
            </MenuCheckboxItem>
            <MenuCheckboxItem checked={dogFriendly} onCheckedChange={setDogFriendly}>
              Dog-friendly only
            </MenuCheckboxItem>
          </MenuGroup>
        </MenuPopup>
      </Menu>
      <p className={styles.caption} aria-live="polite">
        {`View: sorted by ${orders[order].toLowerCase()}`}
        {showClosed ? ' · closed trails shown' : ''}
        {dogFriendly ? ' · dog-friendly only' : ''}
      </p>
    </Ground>
  )
}

API Reference

Groups the parts of a menu (Base UI Menu.Root).

Opens the menu. Renders a Button (§9.2): an outline or text Button with a title-case label (“Sort By”, “Export”) [D160, D165], or an icon-only more_horiz “…". A sort trigger shows its current value.

PropTypeDescription
butted
'start' | 'end' | null | undefined

Butts the button against an adjacent field on its start or end edge, as in the butted submit. Default: none.

handle
MenuHandle<unknown> | undefined

Associates a detached trigger with a Menu created by Menu.createHandle.

icon
| 'menu'
| 'search'
| 'circle'
| 'arrow_forward'
| 'arrow_upward'
| 'expand_more'
| 'close'
| 'remove'
| 'add'
| 'check'
| 'chevron_right'
| 'chevron_left'
| 'more_horiz'
| 'play_arrow'
| 'pause'
| 'download'
| 'zoom_in'
| 'zoom_out'
| 'recenter'
| 'help'
| 'mail'
| undefined

One optional functional glyph (§6.10), inline tier, FILL 0.

iconOnly
boolean | null | undefined

true hides the label and shows only icon. Default false.

iconPosition
'start' | 'end' | undefined

Which side of the label the glyph sits on (a text Button’s chevron trails).

onMedia
false | boolean | null | undefined

Only with iconOnly.

onPress
PressCallback | undefined

Called on click, after onClick.

payload
unknown | undefined

A payload handed to the Menu’s children function when this trigger opens it.

primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale, from the primary roster: the outline edge, labels and focus ring. Never defaulted; omitted, it inherits the scope [D133].

secondary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null
| undefined

Secondary Radix scale: the solid fill and the text glyph. Omitted, solid falls back to the scope’s action scale.

size
'sm' | 'md' | 'lg' | 'xl' | null | undefined

Fixed height: sm 32 px (hit area extended to 44), md 40 px (default), lg 48 px, xl 56 px for the page’s single transactional action.

variant
'text' | 'solid' | 'outline' | null | undefined

outline (default) is the outline twin; solid is the page’s primary action, filled with the scope’s action scale; text is a text button whose glyph trails (§9.2).

children
React.ReactNode | undefined

The label: verb plus object, authored in title case [D160].

The popup, rendered through its Base UI Portal as a nested white scope: --primary1 face, --border-size-2 --primary12 edge, --ds-radius-8, --size-px-1 padding, 200 px to --size-px-14 wide and at least the trigger’s width, --size-px-2 from the trigger, aligned to its start edge. It opens instantly or with a clip reveal from the trigger side. Inside a MenuSubmenu it flies out to the end side.

PropTypeDescription
primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale inside the popup’s white scope. Omitted, the white preset’s default: the popup never takes the trigger’s scales [D133].

secondary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null
| undefined

Secondary Radix scale inside the popup. It drives nothing but a destructive item’s fallback.

container
| HTMLElement
| ShadowRoot
| React.RefObject<HTMLElement | ShadowRoot | null>
| null
| undefined

The element the portal renders into. Default: document.body.

align
Align | undefined

Alignment to the trigger. Default start.

alignOffset
number | OffsetFunction | undefined

Offset along the alignment axis in px.

side
Side | undefined

Side of the trigger. Default bottom for a menu, the end side for a submenu.

sideOffset
number | OffsetFunction | undefined

Distance from the trigger in px. Default 8 (--size-px-2).

collisionPadding
Padding | undefined

Clearance from the viewport edge in px before the popup shifts or flips. Default 16.

keepMounted
boolean | undefined

Keeps the portal mounted while closed.

An action row (Base UI Menu.Item): --ds-size-hit tall at every breakpoint, a --size-px-5 leading slot, the label in type-body-ui sentence case, and an optional trailing shortcut. Highlight (pointer or keyboard) is the --primary4 fill plus the start-edge bar; activation closes the menu.

PropTypeDescription
destructive
boolean | null | undefined

Shows the danger glyph (◆ with its inner ×) in the leading slot; the label, which names the verb and object (“Delete Note”), stays --primary12, so color is never the only cue. Default false.

icon
| 'menu'
| 'search'
| 'circle'
| 'arrow_forward'
| 'arrow_upward'
| 'expand_more'
| 'close'
| 'remove'
| 'add'
| 'check'
| 'chevron_right'
| 'chevron_left'
| 'more_horiz'
| 'play_arrow'
| 'pause'
| 'download'
| 'zoom_in'
| 'zoom_out'
| 'recenter'
| 'help'
| 'mail'
| undefined

A leading Material Symbols glyph at the inline tier (§6.10).

secondary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null
| undefined

The item’s secondary scale. Omitted, a destructive item takes the danger scale [D129].

shortcut
React.ReactNode | undefined

A trailing keyboard shortcut, in type-data --role-muted, end-aligned.

An option that toggles (Base UI Menu.CheckboxItem). Checked shows a leading ✓ in --primary12 and sets the label at --font-weight-6, with no fill and no bar [D145]. It stays open on activation.

Groups radio items into one choice, such as a sort order (Base UI Menu.RadioGroup).

One choice in a MenuRadioGroup (Base UI Menu.RadioItem). Checked shows a leading ● in --primary12 (the FILL 1 circle, always with the weight change) and the label at --font-weight-6; no fill and no bar [D145, D166].

Groups related items under a MenuGroupLabel (Base UI Menu.Group).

A group’s label, in type-label --role-muted; not focusable (Base UI Menu.GroupLabel).

A full-width --border-size-1 --role-rule between groups, --size-px-1 above and below.

Groups a submenu’s trigger and popup (Base UI Menu.SubmenuRoot). The nested MenuPopup flies out to the end side at --size-px-2 and flips at the viewport edge.

The row that opens a submenu (Base UI Menu.SubmenuTrigger), with a trailing chevron_right at the inline tier. It stays highlighted while its submenu is open.

PropTypeDescription
icon
| 'menu'
| 'search'
| 'circle'
| 'arrow_forward'
| 'arrow_upward'
| 'expand_more'
| 'close'
| 'remove'
| 'add'
| 'check'
| 'chevron_right'
| 'chevron_left'
| 'more_horiz'
| 'play_arrow'
| 'pause'
| 'download'
| 'zoom_in'
| 'zoom_out'
| 'recenter'
| 'help'
| 'mail'
| undefined

A leading Material Symbols glyph at the inline tier (§6.10).

Additional types

menu
MenuCheckboxItemProps
MenuGroupLabelProps
MenuGroupProps
menuItem
MenuItemProps
MenuPopupProps
MenuProps
MenuRadioGroupProps
MenuRadioItemProps
MenuSeparatorProps
MenuSubmenuProps
MenuSubmenuTriggerProps
MenuTriggerProps

Specification: DESIGN-SYSTEM.md §9.7 (menu), §9.1 (overlay panels) and §1.11.4 (list-option states).