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.
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.
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.
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.
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".
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.
Submenus
<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.
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.
Links
<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.
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.
start
center
end
top
bottom
left
right
inline-start
inline-end
Disabled
Accessibility
| Key | Behaviour |
|---|---|
| Enter then Space | With the trigger focused, opens the menu and highlights the first row. With the menu open, activates the highlighted row. |
| Arrow Down | Opens the menu from the trigger and highlights the first row; moves the highlight down one row once open. |
| Arrow Up | Opens the menu from the trigger and highlights the last row; moves the highlight up one row once open. |
| Arrow Right | On a submenu trigger, opens the submenu and highlights its first row. Mirrored under RTL. |
| Arrow Left | Inside a submenu, closes it and returns the highlight to the row that opened it. Mirrored under RTL. |
| Home | Highlights the first row. |
| End | Highlights the last row. |
| A–Z | Typeahead: highlights the first row whose text matches what you type. The buffer resets shortly after the last keystroke. |
| Esc | Closes 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.Popupdeclares the surface properties —bg,fg,radius,shadow, the twoborder-*,padding-y,min-width,max-width— plus--forte-menu-item-px,--forte-menu-item-gap,--forte-menu-item-indicator-sizeand--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.Popupalso declares the motion knobs:travel,enter-scale, and theenter-/exit-duration and easing pairs.Menu.Itemand the other row parts declare--forte-menu-item-fg,--forte-menu-item-bg-highlighted,--forte-menu-item-fg-highlightedand--forte-menu-item-font-size.tone="danger"is those first three re-pointed at the danger set.positionerClassNamereaches--forte-menu-z-index, and nothing else: it is the only property declared on the positioner.--forte-menu-backdrop-z-indexis declared on the backdrop, an elementMenu.Popuprenders 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.
| Property | Controls | Default |
|---|---|---|
--forte-menu-backdrop-z-index | Stacking order of the backdrop | 49 |
--forte-menu-z-index | Stacking order of the positioner | 50 |
--forte-menu-bg | Popup surface colour | var(--forte-color-overlay) |
--forte-menu-fg | Popup text colour | var(--forte-color-foreground) |
--forte-menu-radius | Corner radius | var(--forte-radius-surface) |
--forte-menu-shadow | Popup 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-width | Width of the hairline that separates the popup from the page | 1px |
--forte-menu-border-color | Colour of that hairline | var(--forte-color-border) |
--forte-menu-padding-y | Block padding above the first row and below the last | var(--forte-space-1) |
--forte-menu-min-width | Smallest width the popup will take | 12rem |
--forte-menu-max-width | Width cap, further clamped to the space the positioner reports as available | 20rem |
--forte-menu-item-px | Inline padding shared by rows, group labels and separators | var(--forte-space-3) |
--forte-menu-item-gap | Gap between a row's indicator, its label and its shortcut | var(--forte-control-gap) |
--forte-menu-item-indicator-size | Width of the tick / dot column on checkable rows | var(--forte-space-4) |
--forte-menu-item-icon-size | Size a leading icon dropped into a row is drawn at | var(--forte-space-4) |
--forte-menu-travel | How far the popup slides in from, per side | var(--forte-travel-sm) |
--forte-menu-enter-scale | Scale the popup grows from and shrinks back to | var(--forte-scale-enter) |
--forte-menu-enter-duration | Length of the opening gesture | var(--forte-duration-normal) |
--forte-menu-enter-ease | Curve of the opening gesture | var(--forte-ease-spring-snappy) |
--forte-menu-exit-duration | Length of the closing gesture | var(--forte-duration-fast) |
--forte-menu-exit-ease | Curve of the closing gesture | var(--forte-ease-exit) |
--forte-menu-item-fg | Row text colour | var(--forte-color-foreground) |
--forte-menu-item-bg-highlighted | Background of the highlighted row | var(--forte-color-primary-soft) |
--forte-menu-item-fg-highlighted | Text colour of the highlighted row | var(--forte-color-primary-text) |
--forte-menu-item-font-size | Row font size | var(--forte-font-size-2) |
API reference
Menu.Root
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.
Menu.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
closeDelay | number | 0 | How long the menu lingers after the pointer leaves, in milliseconds. Requires `openOnHover`. |
delay | number | 100 | How long the pointer must rest on the trigger before the menu opens, in milliseconds. Requires `openOnHover`. |
handle | MenuHandle<Payload> | Associates a detached trigger with the `Menu.Root` carrying the same handle, created once outside render with `Menu.createHandle()`. | |
nativeButton | boolean | true | Whether 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. |
openOnHover | boolean | false, or true inside a `Menubar` with a menu already open | Also 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. |
payload | Payload | Data 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. | |
render | ReactElement<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. |
Menu.Popup
Renders the whole floating half of the anatomy — portal, optional backdrop, positioner and popup — as one part.
| Prop | Type | Default | Description |
|---|---|---|---|
align | Align | start | How 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. |
alignOffset | number | OffsetFunction | 0; -4 inside a `Menu.SubmenuRoot`; 2 inside a `ContextMenu.Root` | Extra offset in pixels along the alignment axis. |
anchor | Element | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | null | An element to position the popup against instead of the trigger. | |
backdrop | boolean | false | Render 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. |
children | ReactNode | The items, groups and separators to render. | |
className | string | Additional class name(s) for the popup surface. Applied after the internal styles so consumer utilities win without needing `!important`. | |
collisionPadding | Padding | 5 | Space to keep between the popup and the edge of its collision boundary. |
container | HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null | Render the popup into a different container instead of `<body>`. | |
finalFocus | boolean | RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | void | HTMLElement | null) | the trigger | Where 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. |
positionerClassName | string | Additional 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. | |
side | Side | "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. |
sideOffset | number | OffsetFunction | 4; -4 inside a `Menu.SubmenuRoot`; -5 inside a `ContextMenu.Root` | Gap in pixels between the trigger and the popup. |
Menu.Item
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The item's label, plus anything else the row shows — a leading icon, a trailing `<Menu.Shortcut>`. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
closeOnClick | boolean | true | Whether choosing the item closes the menu. Leave it on for a command; turn it off for a row that only changes something on screen. |
disabled | boolean | false | Whether 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. |
label | string | Plain-text label used for typeahead when `children` is not a string. | |
tone | MenuItemTone | neutral | Which 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. |
Menu.LinkItem
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The link's label, plus anything else the row shows. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
closeOnClick | boolean | false | Whether 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. |
href | string | Where the link goes. | |
label | string | Plain-text label used for typeahead when `children` is not a string. | |
render | ReactElement<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. |
Menu.CheckboxItem
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | Whether the item is ticked. Pass it to control the item; use `defaultChecked` for the uncontrolled form. | |
children | ReactNode | The item's label. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
closeOnClick | boolean | false | Whether choosing the item closes the menu. Off by default, which is what lets a run of toggles be set in one visit. |
defaultChecked | boolean | false | Whether the item starts out ticked. |
indicator | ReactNode | <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. |
Menu.RadioGroup
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The radio items, and any group label above them. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
defaultValue | any | The value selected on first render. | |
disabled | boolean | false | Whether the whole group ignores user interaction. |
onValueChange | ((value: any, eventDetails: MenuRootChangeEventDetails) => void) | Called when the selection changes. | |
value | any | The selected value. Pass it to control the group; use `defaultValue` for the uncontrolled form. |
Menu.RadioItem
| Prop | Type | Default | Description |
|---|---|---|---|
value* | any | The value this row selects in its `<Menu.RadioGroup>`. | |
children | ReactNode | The item's label. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
closeOnClick | boolean | false | Whether choosing the item closes the menu. Off by default, matching `Menu.CheckboxItem`. |
indicator | ReactNode | <DotIcon /> | What marks the row as selected. Rendered inside the indicator, which only mounts while the item is the chosen one. |
Menu.Group
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Menu.GroupLabel
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Menu.Separator
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
orientation | Orientation | horizontal | Orientation of the rule. A menu is a vertical list, so the divider between two runs of items is horizontal. |
Menu.SubmenuRoot
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.
Menu.SubmenuTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The row's label. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
disabled | boolean | false | Whether the row ignores user interaction. |
icon | ReactNode | <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. |
label | string | Plain-text label used for typeahead when `children` is not a string. |
Menu.Shortcut
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The keys, written the way they are printed — `⌘K`, `Ctrl+K`, `⇧⌘P`. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |