Skip to content

Menu

A dropdown menu: a button that opens a list of commands. Reach for it when each row does something — duplicate, export, sign out — and the list is short enough to read at a glance. It is the component people usually mean by "dropdown", but it is not a form control: nothing here writes a value into a field. For picking a value out of a fixed set, use Select.

menu/basic.tsx

Import

import { Menu } from "@forte-ui/react";

Examples

Triggers

<Menu.Trigger> renders a modest neutral button so an unstyled menu is not UA chrome. Pass render to hand the element to something else — the library's own <Button> is the usual case — and the trigger's styling steps aside, so the two never fight over the cascade.

menu/trigger.tsx

Icons and shortcuts

<Menu.Shortcut> prints the keys at the end of a row, drawn as a Kbd cap — the same cap Tooltip.Shortcut and running text use, dimmed and highlighted with the row because the cap derives its chrome from the text colour it inherits. A direct <svg> child of a row is sized to --forte-menu-item-icon-size and never squeezed by a long label, so an icon set's own default (lucide draws at 24px) does not decide the row's height.

menu/shortcuts.tsx

Checkable rows

<Menu.CheckboxItem> toggles a setting; <Menu.RadioItem> inside a <Menu.RadioGroup> picks one of a set. Both default to closeOnClick={false}, which is what lets a run of toggles be set in one visit — the opposite of <Menu.Item>, which closes on click because a command is done once it has run.

menu/checkbox-items.tsx

Showing: Status, Assignee

The marker is a tick for a checkbox row and a dot for a radio row, the same distinction the platform menus draw — and once both kinds share a popup, the only thing separating "any of these" from "one of these".

menu/radio-items.tsx

Sorted by newest first.

Rendering one checkable row anywhere in a popup indents every row in that popup to match, so labels stay in one column instead of stepping in and out around the tick marks. A menu of plain commands keeps its tight left edge and spends no space on a gutter nothing will fill. It is decided per popup, so a submenu full of checkboxes does not put a gutter in the menu that opened it.

Groups

<Menu.Group> renders role="group" and takes its accessible name from the <Menu.GroupLabel> inside it, so screen readers announce the heading along with its members. <Menu.Separator> is the visual counterpart — use both when the grouping has a name, the separator alone when it does not.

menu/groups.tsx

<Menu.SubmenuRoot> goes inside the parent popup, where <Menu.SubmenuTrigger> takes the place of a row. The submenu's own <Menu.Popup> needs no positioning props: it reads the nesting from the SubmenuRoot above it and switches to side="inline-end", align="start" and two negative offsets that tuck it against the parent so the first row lines up with the trigger. Pass any of side, align, sideOffset or alignOffset explicitly to override that.

menu/submenu.tsx

A submenu opens on hover as well as on press, and Base UI's safe triangle keeps it up while the pointer travels diagonally toward it — so crossing a neighbouring row on the way does not close the thing you were reaching for. The parent row stays filled for as long as its submenu is up, which is the trail back.

<Menu.LinkItem> renders an <a> with role="menuitem": it keeps the menu's arrow-key navigation and typeahead while still being a real link, so middle-click, ⌘-click and "copy link address" all work. None of those work on an item whose onClick calls router.push. Pass render={<Link href="…" />} to route it through a framework's own link component.

menu/links.tsx

Placement

side and align place the popup against the trigger; both are hints, and the popup flips to the opposite side or shifts along the alignment axis rather than overflow the viewport. Every combination Base UI accepts is below.

All three alignments work — the only thing this library changes is which one you get for free: align defaults to "start" rather than Base UI's "center", because a centred menu drifts left of a wide trigger and right of a narrow one, and the reading edge is what a list of commands should line up on. Pass align="center" back when the trigger is the visual anchor, as an icon-only button in a toolbar usually is.

menu/placement.tsx

start

center

end

top

bottom

left

right

inline-start

inline-end

Disabled

menu/disabled.tsx

Accessibility

Keyboard interactions
KeyBehaviour
Enter then SpaceWith the trigger focused, opens the menu and highlights the first row. With the menu open, activates the highlighted row.
Arrow DownOpens the menu from the trigger and highlights the first row; moves the highlight down one row once open.
Arrow UpOpens the menu from the trigger and highlights the last row; moves the highlight up one row once open.
Arrow RightOn a submenu trigger, opens the submenu and highlights its first row. Mirrored under RTL.
Arrow LeftInside a submenu, closes it and returns the highlight to the row that opened it. Mirrored under RTL.
HomeHighlights the first row.
EndHighlights the last row.
A–ZTypeahead: highlights the first row whose text matches what you type. The buffer resets shortly after the last keystroke.
EscCloses the menu and returns focus to the trigger. Inside a submenu it closes only that submenu, unless closeParentOnEsc is set.

The highlight wraps by default: Arrow Down on the last row returns to the first. Set loopFocus={false} on <Menu.Root> if it should stop at the ends instead.

data-highlighted is the focus analogue for rows. Base UI moves real DOM focus between them with a roving tabindex, so the highlighted row is the focused element, and pointer hover sets the same attribute (highlightItemOnHover on <Menu.Root>, on by default — set it to false if you need CSS :hover to be distinguishable from the focused state). Style rows off [data-highlighted] rather than :hover, or keyboard users get no highlight at all.

Because the popup scrolls once it runs out of room, and a scroll container clips, every row applies its focus ring inset via data-focus-inset — the ring lands inside the row's own box, where overflow cannot shave it off.

Theming

Every --forte-menu-* property below is declared on the surface that owns it, and an element's own declaration beats an inherited value — so setting one on :root, on a theme scope or on any ancestor has no effect. The popup is portalled to <body> as well, so an ancestor of the trigger is not even in its tree. Set each property on the part that declares it, through that part's className (an unlayered rule beats the library's @layer forte.components, whatever its specificity) or an inline style:

  • Menu.Popup declares the surface properties — bg, fg, radius, shadow, the two border-*, padding-y, min-width, max-width — plus --forte-menu-item-px, --forte-menu-item-gap, --forte-menu-item-indicator-size and --forte-menu-item-icon-size. Those four are the layout knobs the rows, the group labels and the separators read but never redeclare, so the popup is the right place to set them.
  • Menu.Popup also declares the motion knobs: travel, enter-scale, and the enter-/exit- duration and easing pairs.
  • Menu.Item and the other row parts declare --forte-menu-item-fg, --forte-menu-item-bg-highlighted, --forte-menu-item-fg-highlighted and --forte-menu-item-font-size. tone="danger" is those first three re-pointed at the danger set.
  • positionerClassName reaches --forte-menu-z-index, and nothing else: it is the only property declared on the positioner.
  • --forte-menu-backdrop-z-index is declared on the backdrop, an element Menu.Popup renders internally and exposes no class name for, so it cannot be retargeted from outside.

The global tokens these resolve to — --forte-color-*, --forte-control-*, --forte-radius-*, --forte-space-* — are the exception: the component only reads those and never redeclares them, so re-pointing them on :root or on a theme scope does re-skin every menu.

menu/theming.tsx
Theming tokens for Menu
PropertyControlsDefault
--forte-menu-backdrop-z-indexStacking order of the backdrop49
--forte-menu-z-indexStacking order of the positioner50
--forte-menu-bgPopup surface colourvar(--forte-color-overlay)
--forte-menu-fgPopup text colourvar(--forte-color-foreground)
--forte-menu-radiusCorner radiusvar(--forte-radius-surface)
--forte-menu-shadowPopup shadow (stripped by the browser in forced-colors mode, which is why the element also carries .forte-hc-surface)var(--forte-shadow-4)
--forte-menu-border-widthWidth of the hairline that separates the popup from the page1px
--forte-menu-border-colorColour of that hairlinevar(--forte-color-border)
--forte-menu-padding-yBlock padding above the first row and below the lastvar(--forte-space-1)
--forte-menu-min-widthSmallest width the popup will take12rem
--forte-menu-max-widthWidth cap, further clamped to the space the positioner reports as available20rem
--forte-menu-item-pxInline padding shared by rows, group labels and separatorsvar(--forte-space-3)
--forte-menu-item-gapGap between a row's indicator, its label and its shortcutvar(--forte-control-gap)
--forte-menu-item-indicator-sizeWidth of the tick / dot column on checkable rowsvar(--forte-space-4)
--forte-menu-item-icon-sizeSize a leading icon dropped into a row is drawn atvar(--forte-space-4)
--forte-menu-travelHow far the popup slides in from, per sidevar(--forte-travel-sm)
--forte-menu-enter-scaleScale the popup grows from and shrinks back tovar(--forte-scale-enter)
--forte-menu-enter-durationLength of the opening gesturevar(--forte-duration-normal)
--forte-menu-enter-easeCurve of the opening gesturevar(--forte-ease-spring-snappy)
--forte-menu-exit-durationLength of the closing gesturevar(--forte-duration-fast)
--forte-menu-exit-easeCurve of the closing gesturevar(--forte-ease-exit)
--forte-menu-item-fgRow text colourvar(--forte-color-foreground)
--forte-menu-item-bg-highlightedBackground of the highlighted rowvar(--forte-color-primary-soft)
--forte-menu-item-fg-highlightedText colour of the highlighted rowvar(--forte-color-primary-text)
--forte-menu-item-font-sizeRow font sizevar(--forte-font-size-2)

API reference

Groups every part and owns the open state. It renders no DOM element, so it takes neither className nor ref, and it forwards Base UI's own Menu.Root props unchanged — including open, defaultOpen, onOpenChange, modal, orientation, loopFocus, highlightItemOnHover, disabled, and the handle / triggerId pair that lets a detached trigger drive it.

Props for MenuTrigger
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
closeDelaynumber0How long the menu lingers after the pointer leaves, in milliseconds. Requires `openOnHover`.
delaynumber100How long the pointer must rest on the trigger before the menu opens, in milliseconds. Requires `openOnHover`.
handleMenuHandle<Payload>Associates a detached trigger with the `Menu.Root` carrying the same handle, created once outside render with `Menu.createHandle()`.
nativeButtonbooleantrueWhether the rendered element is a real `<button>`. Set it to `false` when `render` replaces the button with something else (a `<div>`, a table row), so Base UI supplies the keyboard and role behaviour the element does not have natively.
openOnHoverbooleanfalse, or true inside a `Menubar` with a menu already openAlso open the menu when the trigger is hovered. Off by default, and worth leaving off for a standalone menu: a menu that opens on hover is a menu that opens by accident on the way to somewhere else. Inside a `<Menubar>` it turns itself on, but only once a sibling menu in the same bar is already open — which is what makes the row behave as one strip after the first click without any of its triggers firing at a passing pointer. Pass it explicitly to override that either way.
payloadPayloadData handed to the menu when this trigger opens it, so one popup can render different items per trigger. Read it from the render-function form of `Menu.Root`'s children.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, MenuTriggerState>Replaces the rendered `<button>` with another element or component — `render={<Button variant="outline" />}` is the common case. The trigger's own neutral styling steps aside when this is present, so the two never fight over the cascade.

Renders the whole floating half of the anatomy — portal, optional backdrop, positioner and popup — as one part.

Props for MenuPopup
PropTypeDefaultDescription
alignAlignstartHow the popup aligns along the chosen side. Menus align to the trigger's start edge rather than its centre, which is Base UI's own default — a centred menu drifts left of a wide trigger and right of a narrow one, and the reading edge is what a list of commands should line up on.
alignOffsetnumber | OffsetFunction0; -4 inside a `Menu.SubmenuRoot`; 2 inside a `ContextMenu.Root`Extra offset in pixels along the alignment axis.
anchorElement | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | nullAn element to position the popup against instead of the trigger.
backdropbooleanfalseRender a dimming layer behind the popup. Off by default — a menu is a short-lived list, not a mode — but useful on small screens where the popup covers most of the page.
childrenReactNodeThe items, groups and separators to render.
classNamestringAdditional class name(s) for the popup surface. Applied after the internal styles so consumer utilities win without needing `!important`.
collisionPaddingPadding5Space to keep between the popup and the edge of its collision boundary.
containerHTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | nullRender the popup into a different container instead of `<body>`.
finalFocusboolean | RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | void | HTMLElement | null)the triggerWhere focus goes when the menu closes. There is no `initialFocus` counterpart: Base UI moves focus into the popup on open, to the first item for a keyboard open and to the popup itself for a pointer one.
positionerClassNamestringAdditional class name(s) for the positioner — the absolutely positioned wrapper around the popup. This is the only way to reach `--forte-menu-z-index`, the one property declared there.
sideSide"bottom"; "inline-end" inside a `Menu.SubmenuRoot`; the popup is anchored to the pointer instead inside a `ContextMenu.Root`Which side of the trigger the popup opens on. Flips automatically to avoid collisions. `"inline-start"` / `"inline-end"` follow writing direction, but Base UI takes that direction from its own `DirectionProvider` context and not from the `dir` attribute — with no provider mounted it is `"ltr"`, so the two resolve to left and right whatever `dir` says. An RTL app has to mount `<DirectionProvider direction="rtl">` for them to mirror. The library's own CSS reads the attribute, so everything else here flips without it.
sideOffsetnumber | OffsetFunction4; -4 inside a `Menu.SubmenuRoot`; -5 inside a `ContextMenu.Root`Gap in pixels between the trigger and the popup.
Props for MenuItem
PropTypeDefaultDescription
childrenReactNodeThe item's label, plus anything else the row shows — a leading icon, a trailing `<Menu.Shortcut>`.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
closeOnClickbooleantrueWhether choosing the item closes the menu. Leave it on for a command; turn it off for a row that only changes something on screen.
disabledbooleanfalseWhether the item ignores user interaction. A disabled item stays in the list, stays announced, and is still reached by the arrow keys and by typeahead — so the command remains discoverable; it just cannot be run.
labelstringPlain-text label used for typeahead when `children` is not a string.
toneMenuItemToneneutralWhich semantic colour set the row draws from. `danger` is for an action that destroys something, and is the only alternative offered: the highlight already paints `--forte-color-primary-soft`, so a primary-toned row would be indistinguishable from the row the user is currently on.
Props for MenuLinkItem
PropTypeDefaultDescription
childrenReactNodeThe link's label, plus anything else the row shows.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
closeOnClickbooleanfalseWhether following the link closes the menu. Base UI leaves this off because a link that opens in a new tab leaves the menu's own page standing — turn it on for a same-tab navigation.
hrefstringWhere the link goes.
labelstringPlain-text label used for typeahead when `children` is not a string.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>, MenuLinkItemState>Replaces the rendered `<a>` with another element or component — `render={<Link href="/settings" />}` is how a framework's router link goes in without losing the menu's keyboard behaviour.
Props for MenuCheckboxItem
PropTypeDefaultDescription
checkedbooleanWhether the item is ticked. Pass it to control the item; use `defaultChecked` for the uncontrolled form.
childrenReactNodeThe item's label.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
closeOnClickbooleanfalseWhether choosing the item closes the menu. Off by default, which is what lets a run of toggles be set in one visit.
defaultCheckedbooleanfalseWhether the item starts out ticked.
indicatorReactNode<CheckIcon />What marks the item as ticked. Rendered inside the indicator, which only mounts while the item is checked.
onCheckedChange((checked: boolean, eventDetails: MenuRootChangeEventDetails) => void)Called when the item is ticked or unticked.
Props for MenuRadioGroup
PropTypeDefaultDescription
childrenReactNodeThe radio items, and any group label above them.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
defaultValueanyThe value selected on first render.
disabledbooleanfalseWhether the whole group ignores user interaction.
onValueChange((value: any, eventDetails: MenuRootChangeEventDetails) => void)Called when the selection changes.
valueanyThe selected value. Pass it to control the group; use `defaultValue` for the uncontrolled form.
Props for MenuRadioItem
PropTypeDefaultDescription
value*anyThe value this row selects in its `<Menu.RadioGroup>`.
childrenReactNodeThe item's label.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
closeOnClickbooleanfalseWhether choosing the item closes the menu. Off by default, matching `Menu.CheckboxItem`.
indicatorReactNode<DotIcon />What marks the row as selected. Rendered inside the indicator, which only mounts while the item is the chosen one.
Props for MenuGroup
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
Props for MenuGroupLabel
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
Props for MenuSeparator
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
orientationOrientationhorizontalOrientation of the rule. A menu is a vertical list, so the divider between two runs of items is horizontal.

Groups a submenu's trigger and popup, and renders no DOM element of its own. It forwards Base UI's Menu.SubmenuRoot props — open, defaultOpen, onOpenChange, disabled, loopFocus, closeParentOnEsc — and is what <Menu.Popup> reads to switch to sideways placement.

Props for MenuSubmenuTrigger
PropTypeDefaultDescription
childrenReactNodeThe row's label.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
disabledbooleanfalseWhether the row ignores user interaction.
iconReactNode<ChevronIcon />The affordance at the end of the row. Defaults to a chevron, which is mirrored in RTL so it keeps pointing at the side the submenu opens on.
labelstringPlain-text label used for typeahead when `children` is not a string.
Props for MenuShortcut
PropTypeDefaultDescription
childrenReactNodeThe keys, written the way they are printed — `⌘K`, `Ctrl+K`, `⇧⌘P`.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.