Skip to Content
Components
Menu ALPHA

Menu

Multi-purpose combobox widget to allow selection from a dynamic set of options.

Documentation

Usage

"use client"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react/unstable"; 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> ); }

Anatomy

import { Menu, MenuContent, MenuTrigger } from "@optiaxiom/react"; export default () => ( <Menu> <MenuTrigger /> <MenuContent /> </Menu> );

Structure

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/unstable"; 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> ); }

Selection

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

"use client"; import { toaster } from "@optiaxiom/react"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react/unstable"; import { useMemo } from "react"; const colors = ["Ocean", "Blue", "Purple", "Red", "Orange", "Yellow"]; export function App() { return ( <Menu options={useMemo( () => colors.map<MenuOption>((color) => ({ execute: () => { toaster.create(`Clicked "${color}"`); }, label: color, })), [], )} > <MenuTrigger>Select color</MenuTrigger> <MenuContent /> </Menu> ); }

Single-select

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/unstable"; 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(color), label: color, selected: value === color, })), [value], )} > <MenuTrigger>Select color</MenuTrigger> <MenuContent /> </Menu> ); }

Multi-select

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

"use client"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react/unstable"; 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>Select color</MenuTrigger> <MenuContent /> </Menu> ); }

Input

By default the input is only shown if there are any selectable items (items with a selected property). But we can always show the input by enabling the defaultInputVisible prop.

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

Filtering

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

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

Async loading

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 spinner while the data is loading (an empty state will be shown otherwise).

"use client"; import { Menu, MenuContent, MenuTrigger } from "@optiaxiom/react/unstable"; 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 defaultInputVisible loading={isLoading} onInputValueChange={(inputValue) => refetch(inputValue)} options={data} > <MenuTrigger w="224">Select colors</MenuTrigger> <MenuContent /> </Menu> ); }

Empty

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/unstable"; const colors = [ { label: "Ocean" }, { label: "Blue" }, { label: "Purple" }, { label: "Red" }, { label: "Orange" }, { label: "Yellow" }, ]; export function App() { return ( <Menu defaultInputVisible empty="No colors matched." options={colors}> <MenuTrigger>Select color</MenuTrigger> <MenuContent /> </Menu> ); }

Trigger

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

Addons

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

"use client"; import { Box } from "@optiaxiom/react"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react/unstable"; 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> ); }

Links

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

"use client"; import { Menu, MenuContent, type MenuOption, MenuTrigger, } from "@optiaxiom/react/unstable"; import { useMemo, useState } from "react"; const users = [ { label: "Buckaroo Banzai", link: "https://www.imdb.com/title/tt0086856/", }, { 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> ); }

Submenus

Use subOptions property to render submenus.

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

Virtualized

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/unstable"; 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> ); }

Related

DropdownMenu

Display a dropdown menu.

Select

Single select combobox widget to allow selection from a fixed set of options.

Props

Menu

Doesn't render its own HTML element.

Prop

defaultInputVisible

false | true

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

loading

Whether to show loading spinner inside the menu.

false | true

onHover

Handler that is called when an item is hovered via mouse.

(item: CommandOption) => void

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

string

Default: Filter...

size

"sm" | "lg"

MenuTrigger

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"

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

hasCustomAnchor

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

MenuContent

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

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

Changelog

1.3.0

  • 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, }, ]} />

0.4.0

  • Added component
Last updated on