Skip to content

Menu ​

A dropdown menu of actions, as a compound component on Base UI's menu. Every part takes Base UI's own props; the library adds the look, the portal and the placement.

tsx
import { Menu } from '@adecore/ui';
tsx
import { useState } from 'react';
import { Copy, Ellipsis, FolderInput, Pencil, Trash } from 'lucide-react';
import { EDIT_SHORTCUTS, Icon, IconButton, KEY_SHORTCUTS, Kbd, Menu } from '@adecore/ui';

export default function MenuDemo() {
    const [wrap, setWrap] = useState(true);
    const [sort, setSort] = useState('name');

    return (
        <Menu.Root>
            <IconButton icon={Ellipsis} label="More" render={<Menu.Trigger />} />
            <Menu.Popup>
                <Menu.Item>
                    <Icon icon={Pencil} size={14} /> Rename <Kbd shortcut={KEY_SHORTCUTS.rename} />
                </Menu.Item>
                <Menu.Item>
                    <Icon icon={Copy} size={14} /> Copy path <Kbd shortcut={EDIT_SHORTCUTS.copy} />
                </Menu.Item>
                <Menu.SubmenuRoot>
                    <Menu.SubmenuTrigger>
                        <Icon icon={FolderInput} size={14} /> Move to
                    </Menu.SubmenuTrigger>
                    <Menu.Popup>
                        <Menu.Item>Documents</Menu.Item>
                        <Menu.Item>
                            Archive <Menu.Hint>Read only</Menu.Hint>
                        </Menu.Item>
                        <Menu.Item disabled>Shared</Menu.Item>
                    </Menu.Popup>
                </Menu.SubmenuRoot>
                <Menu.Separator />
                <Menu.CheckboxItem checked={wrap} onCheckedChange={setWrap}>
                    Wrap lines
                </Menu.CheckboxItem>
                <Menu.Group>
                    <Menu.GroupLabel>Sort by</Menu.GroupLabel>
                    <Menu.RadioGroup value={sort} onValueChange={setSort}>
                        <Menu.RadioItem value="name">Name</Menu.RadioItem>
                        <Menu.RadioItem value="modified">Last modified</Menu.RadioItem>
                    </Menu.RadioGroup>
                </Menu.Group>
                <Menu.Separator />
                <Menu.Item>
                    <Icon icon={Trash} size={14} /> Delete
                </Menu.Item>
            </Menu.Popup>
        </Menu.Root>
    );
}

Parts ​

PartWhat it is
Menu.RootHolds the open state. Takes open, onOpenChange and defaultOpen, or keeps the state itself.
Menu.TriggerThe button that opens the menu, usually handed to an IconButton or a Button through render.
Menu.PopupThe portal, the positioner and the popup in one part.
Menu.ItemA row that acts. onClick runs it, disabled grays it out, and closeOnClick={false} keeps the menu open. unstyled makes it an item that is no row (below).
Menu.CheckboxItemA row that is on or off, with checked and onCheckedChange. Draws its box before the label, or at the end with indicator="end".
Menu.RadioGroup, Menu.RadioItemOne of several. The group takes value and onValueChange, each item a value. indicator="end" puts the tick at the end of the row.
Menu.Group, Menu.GroupLabelA group of rows under a label a screen reader reads as the group's name.
Menu.LabelA label above rows that are not a group of their own, such as a row of swatches.
Menu.SeparatorThe hairline between groups.
Menu.HintA quiet note at the end of a row about what the item does. A shortcut is a Kbd instead.
Menu.CheckThe tick slot of a row that shows a state but is not a checkbox or radio item.
Menu.SubmenuRoot, Menu.SubmenuTriggerA row that opens another menu beside it. Put a Menu.Popup inside the SubmenuRoot. The trigger ends in a chevron; chevron={false} leaves it off for a row that ends in a hint.
Menu.Row, Menu.RowAction, Menu.RowSubmenuTriggerA row with buttons at its end (below).

Placement ​

A menu opens under its trigger, start-aligned, 6 pixels away and at least 200 pixels wide. A submenu lines its first row up with the row that opened it. Menu.Popup takes Base UI's placement props (side, align, sideOffset, alignOffset, collisionPadding, collisionBoundary, collisionAvoidance, anchor, sticky, positionMethod). A long menu scrolls.

Doubled separators, and separators at the top or bottom, are hidden, so conditional rows never leave a stray line.

Checks and labels ​

Menu.Check puts a tick in a plain row, for a choice that acts on click or a row with a second line. kind="radio" is a bare tick; kind="checkbox" boxes it, saying the row can be off.

tsx
import { useState } from 'react';
import { Button, ColorSwatch, Menu } from '@adecore/ui';

const LAYOUTS = ['Columns', 'Rows', 'Grid'];

const COLORS = ['#2563eb', '#7c3aed', '#e11d48', '#16a34a'];

export default function MenuCheckDemo() {
    const [layout, setLayout] = useState('Columns');
    const [color, setColor] = useState(COLORS[0]);

    return (
        <Menu.Root>
            <Menu.Trigger render={<Button variant="secondary" />}>Layout</Menu.Trigger>
            <Menu.Popup align="center">
                {LAYOUTS.map((entry) => (
                    <Menu.Item key={entry} closeOnClick={false} onClick={() => setLayout(entry)}>
                        <Menu.Check kind="radio" checked={layout === entry} />
                        {entry}
                    </Menu.Item>
                ))}
                <Menu.Separator />
                <Menu.Label>Color</Menu.Label>
                <div className="flex gap-2 px-2.5 py-1.5">
                    {COLORS.map((swatch) => (
                        <ColorSwatch
                            key={swatch}
                            render={<Menu.Item unstyled closeOnClick={false} />}
                            aria-label={swatch}
                            color={swatch}
                            picked={color === swatch}
                            on="popup"
                            onClick={() => setColor(swatch)}
                        />
                    ))}
                </div>
            </Menu.Popup>
        </Menu.Root>
    );
}

Parts that are not rows ​

unstyled on Menu.Item, Menu.CheckboxItem and Menu.RadioItem drops the row's padding, highlight and indicator. The part stays an item: arrow keys and typeahead reach it, and picking it closes the menu unless closeOnClick={false}. Style it yourself on data-highlighted and data-checked. Use it for an icon button in a heading row, a link at the foot, or a ColorSwatch in a grid. Menu.GroupLabel takes unstyled too.

tsx
import { useState } from 'react';
import { ChevronRight, Plus, Settings2 } from 'lucide-react';
import { Button, Icon, IconButton, Menu } from '@adecore/ui';

const DENSITIES = ['Compact', 'Normal', 'Roomy'];

export default function MenuUnstyledDemo() {
    const [density, setDensity] = useState('Normal');
    const [previews, setPreviews] = useState(true);

    return (
        <Menu.Root>
            <Menu.Trigger render={<Button variant="secondary" />}>
                <Icon icon={Settings2} size={14} /> View
            </Menu.Trigger>
            <Menu.Popup className="w-72">
                <div className="flex items-center gap-1 py-0.5 pr-0.5 pl-2.5 text-sm text-text-muted">
                    <span className="grow">Lists</span>
                    <IconButton icon={Plus} size="xs" label="Add a list" render={<Menu.Item unstyled />} />
                </div>
                <Menu.Separator />
                <div className="flex items-center gap-2.5 px-2.5 py-1.5 text-sm text-text-muted">
                    <span className="grow">Density</span>
                    <Menu.RadioGroup
                        value={density}
                        onValueChange={setDensity}
                        aria-label="Density"
                        className="flex gap-0.5 rounded-md bg-surface-sunken p-0.5"
                    >
                        {DENSITIES.map((entry) => (
                            <Menu.RadioItem
                                key={entry}
                                unstyled
                                value={entry}
                                closeOnClick={false}
                                className="focus-ring flex h-6 items-center rounded px-2 text-xs data-checked:bg-surface-active data-checked:text-text data-highlighted:text-text"
                            >
                                {entry}
                            </Menu.RadioItem>
                        ))}
                    </Menu.RadioGroup>
                </div>
                <Menu.CheckboxItem checked={previews} onCheckedChange={setPreviews} closeOnClick={false} indicator="end" className="text-text-muted">
                    <span className="grow">Previews</span>
                </Menu.CheckboxItem>
                <div className="flex justify-end px-2.5 py-1.5 text-xs">
                    <Menu.Item unstyled className="focus-ring flex items-center gap-0.5 rounded font-medium text-accent data-highlighted:underline">
                        All settings <Icon icon={ChevronRight} size={12} />
                    </Menu.Item>
                </div>
            </Menu.Popup>
        </Menu.Root>
    );
}

Hand the part to another component through render, or give it classes. Its keyboard outline comes from what it renders, such as IconButton, or from .focus-ring.

A row with buttons ​

Menu.Row puts buttons at the end of a row, and the row highlights as one. Its first child is the row itself, a Menu.Item or a Menu.SubmenuTrigger. Name the group with aria-label, usually the row's label.

Menu.RowAction acts at once. It takes an icon, a label (accessible name and tooltip) and onClick. closeOnClick={false} keeps the menu open. iconClassName reaches the icon. Menu.RowSubmenuTrigger looks the same and opens a submenu; put it in a Menu.SubmenuRoot with a Menu.Popup. It has no tooltip.

tsx
import { useState } from 'react';
import { Circle, Ellipsis, ListTodo, Pencil, Play, Square, Trash } from 'lucide-react';
import { Button, Icon, Menu } from '@adecore/ui';

const TASKS = ['Dev server', 'Tests', 'Docs'];

export default function MenuRowDemo() {
    const [running, setRunning] = useState<string[]>(['Dev server']);
    const [message, setMessage] = useState('');

    const toggle = (task: string) => setRunning((tasks) => (tasks.includes(task) ? tasks.filter((entry) => entry !== task) : [...tasks, task]));

    return (
        <div className="flex flex-col items-start gap-3">
            <Menu.Root>
                <Menu.Trigger render={<Button variant="secondary" />}>
                    <Icon icon={ListTodo} size={14} /> Tasks
                </Menu.Trigger>
                <Menu.Popup className="min-w-64">
                    {TASKS.map((task) => {
                        const live = running.includes(task);

                        return (
                            <Menu.Row key={task} aria-label={task}>
                                <Menu.Item onClick={() => setMessage(`Show the output of ${task}`)}>
                                    <Icon icon={Circle} size={12} className={live ? 'fill-current text-status-running' : 'text-text-faint'} /> {task}
                                </Menu.Item>
                                <Menu.RowAction
                                    icon={live ? Square : Play}
                                    label={live ? `Stop ${task}` : `Start ${task}`}
                                    iconClassName="fill-current"
                                    closeOnClick={false}
                                    onClick={() => toggle(task)}
                                />
                                <Menu.SubmenuRoot>
                                    <Menu.RowSubmenuTrigger icon={Ellipsis} label={`More for ${task}`} />
                                    <Menu.Popup>
                                        <Menu.Item onClick={() => setMessage(`Edit ${task}`)}>
                                            <Icon icon={Pencil} size={14} /> Edit
                                        </Menu.Item>
                                        <Menu.Item onClick={() => setMessage(`Remove ${task}`)}>
                                            <Icon icon={Trash} size={14} /> Remove
                                        </Menu.Item>
                                    </Menu.Popup>
                                </Menu.SubmenuRoot>
                            </Menu.Row>
                        );
                    })}
                </Menu.Popup>
            </Menu.Root>
            <span role="status" className="text-xs text-text-muted">
                {message || `${running.length} of ${TASKS.length} running.`}
            </span>
        </div>
    );
}

The arrow keys reach each button after its row. Typeahead reads only the row's label.

Shortcuts in a row ​

A Kbd in a row sits at its end, faint. It only prints the shortcut; bind the key once outside the menu and call the same function.

Keyboard ​

KeyAction
Arrow up, Arrow downMove
Home, EndJump to the first or last row
TypingFind a row by its label
Arrow right, Arrow leftOpen or close a submenu
Enter, SpacePick the row
EscapeClose and focus the trigger

See input modality for how the highlight differs under keyboard and pointer.

Types ​

MenuPopupProps, MenuCheckProps, MenuLabelProps, MenuHintProps, MenuRowProps, MenuRowActionProps and MenuRowSubmenuTriggerProps are exported types.

Licenses and third-party notices are listed in each package.