Skip to Content
ComponentsMenu

Menu

Dropdown menu for displaying actions.

Documentation

"use client"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react"; import { useMemo, useState } from "react"; const priorities = ["No priority", "Urgent", "High", "Medium", "Low"]; export function App() { const [value, setValue] = useState<string>("No priority"); return ( <Menu options={useMemo( () => priorities.map<MenuOption>((priority) => ({ execute: () => setValue(priority), label: priority, selected: priority === value, })), [value], )} > <MenuTrigger w="224"> {value !== "No priority" ? value : "Set priority"} </MenuTrigger> <MenuContent /> </Menu> ); }
import { Menu, MenuContent, MenuTrigger } from "@optiaxiom/react"; export default () => ( <Menu> <MenuTrigger /> <MenuContent /> </Menu> );

Menu works with lists of items provided via the options prop. The basic structure includes the main component provider, a trigger, and the content popover.

  • Menu
  • MenuTrigger
  • MenuContent

Items must be an array of objects of MenuOption type.

import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react"; const colors: MenuOption[] = [ { label: "Ocean" }, { label: "Blue" }, { label: "Purple" }, { label: "Red" }, { label: "Orange" }, { label: "Yellow" }, ]; export function App() { return ( <Menu options={colors}> <MenuTrigger>Select color</MenuTrigger> <MenuContent /> </Menu> ); }

Clicking the items will not do anything yet. For that we’ll have to add the execute property to each item.

Selected:

"use client"; import { Flex, Menu, MenuContent, type MenuOption, MenuTrigger, Text, } from "@optiaxiom/react"; import { useMemo, useState } from "react"; const colors = ["Ocean", "Blue", "Purple", "Red", "Orange", "Yellow"]; export function App() { const [value, setValue] = useState(""); return ( <Flex> <Menu options={useMemo( () => colors.map<MenuOption>((color) => ({ execute: () => setValue(color), label: color, })), [], )} > <MenuTrigger>Select color</MenuTrigger> <MenuContent /> </Menu> <Text fontSize="md">Selected: {value}</Text> </Flex> ); }

Now that we can perform actions on selection, we can also store that state and show the selected states for each item using the selected property.

"use client"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react"; import { useMemo, useState } from "react"; const colors = ["Ocean", "Blue", "Purple", "Red", "Orange", "Yellow"]; export function App() { const [value, setValue] = useState<(typeof colors)[number]>(); return ( <Menu options={useMemo( () => colors.map<MenuOption>((color) => ({ execute: () => setValue((value) => (value === color ? "" : color)), label: color, selected: value === color, })), [value], )} > <MenuTrigger w="224">{value || "Select color"}</MenuTrigger> <MenuContent /> </Menu> ); }

We can render multi-select items by enabling the multi property.

"use client"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react"; import { useMemo, useState } from "react"; const colors = ["Ocean", "Blue", "Purple", "Red", "Orange", "Yellow"]; export function App() { const [value, setValue] = useState<string[]>([]); return ( <Menu options={useMemo( () => colors.map<MenuOption>((color) => ({ execute: () => setValue((value) => value.includes(color) ? value.filter((v) => v !== color) : [...value, color], ), label: color, multi: true, selected: value.includes(color), })), [value], )} > <MenuTrigger w="224"> {value.length ? `${value.length} selected` : "Select color"} </MenuTrigger> <MenuContent /> </Menu> ); }

By default the input is only shown if there are any selectable items (items with a selected property) or when the user starts typing. But we can always show the input by setting the inputVisible prop to always.

"use client"; import { Menu, MenuContent, MenuTrigger } from "@optiaxiom/react"; const colors = [ { label: "Ocean" }, { label: "Blue" }, { label: "Purple" }, { label: "Red" }, { label: "Orange" }, { label: "Yellow" }, ]; export function App() { return ( <Menu inputVisible="always" options={colors}> <MenuTrigger>Select color</MenuTrigger> <MenuContent /> </Menu> ); }

Menu automatically handles filtering using a built-in fuzzy filter based on the label and keywords properties of items.

import { Menu, MenuContent, MenuTrigger } from "@optiaxiom/react"; export function App() { return ( <Menu options={[ { keywords: "general weekends working days publishing budgeting financial default language business days", label: "Organization", }, { keywords: "create workflow step substep placeholder tasks", label: "Workflows", }, { keywords: "custom fields", label: "Fields", }, { keywords: "form task campaign content model component types content types", label: "Templates", }, ]} > <MenuTrigger>Profile</MenuTrigger> <MenuContent /> </Menu> ); }

We can customize the filter behavior using the visible property on items.

The following example shows how we can build creatable menus by allowing the user to add new entries on the fly.

"use client"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react"; import { useMemo, useState } from "react"; const colors = ["Ocean", "Blue", "Purple", "Red", "Orange", "Yellow"]; export function App() { const [items, setItems] = useState(colors); const [value, setValue] = useState<string>(); return ( <Menu options={useMemo<MenuOption[]>( () => [ ...items.map<MenuOption>((color) => ({ execute: () => setValue(color), label: color, selected: value === color, })), { detail: ({ inputValue }) => `"${inputValue}"`, execute: ({ inputValue }) => { if (inputValue) { setItems((items) => [...items, inputValue]); setValue(inputValue); } }, label: "Create: ", visible: ({ inputValue }) => inputValue ? !items.find( (item) => item.toLowerCase() === inputValue.toLowerCase(), ) : false, }, ], [items, value], )} > <MenuTrigger w="224">{value || "Select colors"}</MenuTrigger> <MenuContent /> </Menu> ); }

We can also manually control options in combination with the inputValue and onInputValueChange prop to load items as the user types.

And we can toggle the loading prop to show a loading state while the data is loading (an empty state will be shown otherwise).

"use client"; import { Menu, MenuContent, MenuTrigger } from "@optiaxiom/react"; import { useQuery } from "./useQuery"; const colors = [ { label: "Ocean", visible: true, }, { label: "Blue", visible: true, }, { label: "Purple", visible: true, }, { label: "Red", visible: true, }, { label: "Orange", visible: true, }, { label: "Yellow", visible: true, }, ]; export function App() { const { data = [], isLoading, refetch, } = useQuery((inputValue: string) => inputValue ? colors.filter((color) => color.label.toLowerCase().startsWith(inputValue.toLowerCase()), ) : colors, ); return ( <Menu inputVisible="always" loading={isLoading && "spinner"} onInputValueChange={(inputValue) => refetch(inputValue)} options={data} > <MenuTrigger w="224">Select colors</MenuTrigger> <MenuContent /> </Menu> ); }

By default menu will display a generic empty content message if no results are found matching your query. We can customize this empty message by setting the empty prop.

"use client"; import { Menu, MenuContent, MenuTrigger } from "@optiaxiom/react"; export function App() { return ( <Menu empty="No colors available." inputVisible="always" options={[]}> <MenuTrigger>Select color</MenuTrigger> <MenuContent /> </Menu> ); }

By default we use the Button component for the menu trigger which accepts all of the existing button props.

import { Menu, MenuContent, MenuTrigger } from "@optiaxiom/react"; import { IconFilter, IconLogout, IconUser } from "@tabler/icons-react"; export function App() { return ( <Menu options={[ { addon: <IconUser />, group: { label: "My Account", }, label: "View Profile", }, { addon: <IconLogout />, group: { hidden: true, label: "Logout", separator: true, }, label: "Logout", }, ]} > <MenuTrigger appearance="subtle" aria-label="Filters" icon={<IconFilter />} /> <MenuContent /> </Menu> ); }

We can also use asChild to render a completely different component. We provide two built-in buttons:

  • AngleMenuButton (default)
  • EllipsisMenuButton
import { EllipsisMenuButton, Menu, MenuContent, MenuTrigger, } from "@optiaxiom/react"; import { IconLogout, IconUser } from "@tabler/icons-react"; export function App() { return ( <Menu options={[ { addon: <IconUser />, group: { label: "My Account", }, label: "View Profile", }, { addon: <IconLogout />, group: { hidden: true, label: "Logout", separator: true, }, label: "Logout", }, ]} > <MenuTrigger asChild> <EllipsisMenuButton appearance="subtle" aria-label="My Account" /> </MenuTrigger> <MenuContent /> </Menu> ); }

We can add the addon and description properties to show additional content inside the items.

"use client"; import { Box, Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react"; import { useMemo, useState } from "react"; import { type Color, colors } from "./data"; export function App() { const [value, setValue] = useState<Color[]>([]); return ( <Menu options={useMemo( () => colors.map<MenuOption>((color) => ({ addon: ( <Box rounded="full" size="10" style={{ backgroundColor: color.color }} /> ), execute: () => setValue((value) => value.includes(color) ? value.filter((v) => v !== color) : [...value, color], ), label: color.label, multi: true, selected: () => value.includes(color), })), [value], )} > <MenuTrigger w="224">Select colors</MenuTrigger> <MenuContent /> </Menu> ); }

Use the intent prop to control the appearance of items.

import { Menu, MenuContent, MenuTrigger } from "@optiaxiom/react"; export function App() { return ( <Menu options={[ { description: "Create a new task", label: "New task" }, { description: "Copy this task", label: "Copy task" }, { group: { hidden: true, label: "Delete", separator: true }, intent: "danger", label: "Delete task", }, ]} > <MenuTrigger>Actions</MenuTrigger> <MenuContent /> </Menu> ); }

Add the href property to options to render them as links.

If the execute property is also present then the default link clicking behavior will be prevented (unless users use a modifier like WinEnter to open in a new tab).

"use client"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react"; import { useMemo, useState } from "react"; const users = [ { href: "https://www.imdb.com/title/tt0086856/", label: "Buckaroo Banzai", }, { label: "Emilio Lizardo" }, { label: "Perfect Tommy" }, ] satisfies MenuOption[]; export function App() { const [value, setValue] = useState<(typeof users)[number]>(); return ( <Menu options={useMemo( () => users.map<MenuOption>((user) => ({ execute: () => setValue(user), selected: value === user, ...user, })), [value], )} > <MenuTrigger>Select assignee</MenuTrigger> <MenuContent /> </Menu> ); }

If the execute property is omitted then clicking an item will simply follow the link and close the menu.

import { Menu, MenuContent, MenuTrigger } from "@optiaxiom/react"; export function App() { return ( <Menu options={[ { href: "../../", label: "Home", }, { href: "../", label: "Components", }, ]} > <MenuTrigger>Navigate to section</MenuTrigger> <MenuContent /> </Menu> ); }

Use group property to group items.

"use client"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react"; const groups = { display: { label: "Publishing Options", }, } satisfies Record<string, MenuOption["group"]>; export function App() { return ( <Menu options={[ { group: groups.display, label: "Save to Library", selected: true, }, { group: groups.display, label: "Overwrite Existing", }, { label: "Copy Link", }, ]} > <MenuTrigger>Settings</MenuTrigger> <MenuContent /> </Menu> ); }

Use group.separator to show a horizontal separator before/after the group.

"use client"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react"; const groups = { display: { label: "Publishing Options", separator: true, }, } satisfies Record<string, MenuOption["group"]>; export function App() { return ( <Menu options={[ { group: groups.display, label: "Save to Library", selected: true, }, { group: groups.display, label: "Overwrite Existing", }, { label: "Copy Link", }, ]} > <MenuTrigger>Settings</MenuTrigger> <MenuContent /> </Menu> ); }

Use group.hidden property to hide the group label. Useful when you want to separate groups but do not want show any labels.

"use client"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react"; const groups = { display: { hidden: true, label: "Publishing Options", separator: true, }, } satisfies Record<string, MenuOption["group"]>; export function App() { return ( <Menu options={[ { group: groups.display, label: "Save to Library", selected: true, }, { group: groups.display, label: "Overwrite Existing", }, { label: "Copy Link", }, ]} > <MenuTrigger>Settings</MenuTrigger> <MenuContent /> </Menu> ); }

Use group.priority property to sort groups among each other.

"use client"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react"; const groups = { delete: { hidden: true, label: "Delete", priority: -100, separator: true, }, edit: { hidden: true, label: "Edit", separator: true, }, } satisfies Record<string, MenuOption["group"]>; export function App() { return ( <Menu options={[ { group: groups.delete, intent: "danger", label: "Delete", }, { group: groups.edit, label: "View", }, { group: groups.edit, label: "Download", }, ]} > <MenuTrigger>Settings</MenuTrigger> <MenuContent /> </Menu> ); }

Use subOptions property to render submenus.

"use client"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react"; import { IconBooks, IconLink, IconPlus, IconSparkles, IconUpload, } from "@tabler/icons-react"; const options: MenuOption[] = [ { addon: <IconUpload />, label: "Select from…", subOptions: [ { addon: <IconUpload />, label: "Your device", }, { addon: <IconBooks />, label: "Library", }, ], }, { addon: <IconLink />, label: "Add URL", }, { addon: <IconSparkles />, label: "Generate", }, ]; export function App() { return ( <Menu options={options}> <MenuTrigger icon={<IconPlus />} iconPosition="start"> Add Content </MenuTrigger> <MenuContent /> </Menu> ); }

Combine with dialogs to show modals or alerts when selecting an item.

Make sure to use AlertDialog or Dialog in controlled mode since there is no actual trigger.

"use client"; import { AlertDialog, AlertDialogAction, AlertDialogBody, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, Menu, MenuContent, MenuTrigger, } from "@optiaxiom/react"; import { IconPencil, IconTrash } from "@tabler/icons-react"; import { useState } from "react"; export function App() { const [open, setOpen] = useState(false); return ( <Menu options={[ { addon: <IconPencil />, label: "Edit", }, { addon: <IconTrash />, execute: () => setOpen(true), intent: "danger", label: "Delete", }, ]} > <MenuTrigger>Open</MenuTrigger> <MenuContent /> <AlertDialog onOpenChange={setOpen} open={open}> <AlertDialogContent> <AlertDialogHeader>Are you sure?</AlertDialogHeader> <AlertDialogBody> The task and all content will be deleted. </AlertDialogBody> <AlertDialogFooter> <AlertDialogCancel /> <AlertDialogAction>Yes, delete</AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </Menu> ); }

Menu automatically uses virtualization to improve performance when rendering a large number of items.

"use client"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react"; import { useMemo, useState } from "react"; const colors = Array.from({ length: 2000 }).map( (_, index) => `Color ${index + 1}`, ); export function App() { const [value, setValue] = useState([colors[0]]); return ( <Menu options={useMemo( () => colors.map<MenuOption>((color) => ({ execute: () => setValue((value) => value.includes(color) ? value.filter((v) => v !== color) : [...value, color], ), label: color, multi: true, selected: () => value.includes(color), })), [value], )} > <MenuTrigger w="224">Select colors</MenuTrigger> <MenuContent /> </Menu> ); }

Props

Doesn't render its own HTML element.

Prop

defaultOpen

The initial open state in uncontrolled mode.

false | true

Default: false

empty

Custom empty state content.

ReactNode

inputValue

The input value in controlled mode.

string

inputVisible

Whether to always show the input or only if there are selectable options and/or when user starts to type.

"always" | "if-needed"

loading

Whether to show loading spinner or skeleton items inside the menu.

false | true | "both" | "spinner" | "skeleton"

onInputValueChange

Handler that is called when input value changes.

(inputValue: string) => void

onOpenChange

Handler that is called when the open state changes.

(open: boolean) => void

open

The open state in controlled mode.

false | true

options*

The items we want to render.

CommandOption[] | readonly CommandOption[]

placeholder

The placeholder for the search input.

string

Default: Filter...

size

Whether to show a small popover or a dialog. Defaults to popover on large screens and dialog on mobile screens.

"sm" | "lg"

Supports all Button props in addition to its own. Renders a <button> element.

Prop

addonAfter

Display content inside the button after children.

ReactNode

addonBefore

Display content inside the button before children.

ReactNode

appearance

Control the appearance by selecting between the different button types.

"default" | "danger" | "primary" | "subtle" | "danger-outline" | "inverse" | "primary-opal"

asChild

Change the default rendered element for the one passed as a child, merging their props and behavior.

Read the Composition guide for more details.

false | true

className

string

disabled

Whether the button is disabled.

false | true

icon

Display an icon before or after the button content or omit children to only show the icon.

ReactNode

iconPosition

Control whether to show the icon before or after the button content.

"end" | "start"

loading

Whether to show loading spinner inside the button.

false | true

size

Control the size of the button.

"sm" | "md" | "lg"

square

Whether button should have square shape.

false | true

Supports all Box props in addition to its own. Renders a <div> element.

Prop

align

"center" | "end" | "start"

alignOffset

number

asChild

Change the default rendered element for the one passed as a child, merging their props and behavior.

Read the Composition guide for more details.

false | true

className

string

minW

Whether to set the min-width to the width of the trigger.

"0" | "trigger"

onCloseAutoFocus

Event handler called when auto-focusing on close. Can be prevented.

(event: Event) => void

onEscapeKeyDown

Event handler called when the escape key is down. Can be prevented.

(event: KeyboardEvent) => void

onFocusOutside

Event handler called when the focus moves outside of the DismissableLayer. Can be prevented.

(event: FocusOutsideEvent) => void

onInteractOutside

Event handler called when an interaction happens outside the DismissableLayer. Specifically, when a pointerdown event happens outside or focus moves outside of it. Can be prevented.

(event: FocusOutsideEvent | PointerDownOutsideEvent) => void

onOpenAutoFocus

Event handler called when auto-focusing on open. Can be prevented.

(event: Event) => void

onPointerDownOutside

Event handler called when the a pointerdown event happens outside of the DismissableLayer. Can be prevented.

(event: PointerDownOutsideEvent) => void

side

"bottom" | "left" | "right" | "top"

sideOffset

number

Accessibility

Key

Description

SpaceWhen focus is on MenuTrigger, opens the menu and focuses the first item (or selected item). When focus is on an item, activates the focused item.
EnterWhen focus is on MenuTrigger, opens the menu and focuses the first item (or selected item). When focus is on an item, activates the focused item and closes the menu.
ArrowDownWhen focus is on MenuTrigger, opens the menu and focuses the first item (or selected item). When focus is on an item, moves focus to the next item.
ArrowUpWhen focus is on an item, moves focus to the previous item.
PageUp PageDown
Moves focus up/down by 10 items at a time.
Home End
Moves focus to the first/last item.
ArrowRight ArrowLeft
When focus is on an item with a submenu, opens/closes the submenu.
A - Z
When focus is on an item, filter and display matching items as you type.
EscCloses the menu and moves focus back to MenuTrigger.

Changelog

  • Moved component out of Alpha.

    // Before import { Menu, MenuContent, MenuTrigger } from "@optiaxiom/react/unstable"; // After import { Menu, MenuContent, MenuTrigger } from "@optiaxiom/react";
  • Renamed Combobox component to Menu:

    // Before <Combobox> <ComboboxTrigger /> <ComboboxContent /> </Combobox> // After <Menu> <MenuTrigger /> <MenuContent /> </Menu>
  • Renamed items prop to options:

    // Before <Combobox items={[]} /> // After <Combobox options={[]} />
  • Removed itemToLabel and isItemSelected props in favor of fixed properties on options:

    // Before <Combobox items={[ { id: "1", name: "Sample", }, ]} isItemSelected={(item) => value.includes(item)} itemToLabel={(item) => item.name} /> // After <Combobox options={[ { label: "Sample", selected: true, }, ]} />
  • Added component
Last updated on