> ## Documentation Index
> Fetch the complete documentation index at: https://anvil.servicetitan.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Menu - Web (AI Kit)

> Menus present a list of actions or options from a trigger.

> [!NOTE]
>
> For development, import `Menu` from
> `@servicetitan/anvil2-ai-kit`. For design, find it in the
> [AI Kit Web Component Library file](https://www.figma.com/design/QEIsArLktLeJAaFvcpw3PO/AI-Kit-Web-Component-Library).

ai-kit-menu

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu?file=src/App.tsx)

```tsx lines theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function App() {
  return (
    <div style={{ paddingTop: 16, display: "flex", justifyContent: "center" }}>
      <Menu
        trigger={<Button label="Options" variant="secondary" />}
        items={[
          { id: "rename", label: "Rename" },
          { id: "duplicate", label: "Duplicate" },
          { id: "archive", label: "Move to archive" },
          { id: "delete", label: "Delete" },
        ]}
        defaultOpen
      />
    </div>
  );
}

export default App;
```

## Options

The Menu supports an action mode and two persisted selection modes, leading icons, disabled options, and either edge of its trigger for the popover.

### Selection Mode

`selectionMode` switches the menu between three behaviors: `"none"` (the default) is a plain action menu; `"single"` and `"multiple"` persist a checked selection instead of closing on pick.

#### Single Select

ai-kit-menu-selection-mode-single

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu-selection-mode-single?file=src/App.tsx)

```tsx lines theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function App() {
  return (
    <div style={{ paddingTop: 16, display: "flex", justifyContent: "center" }}>
      <Menu
        trigger={<Button label="Sort by" variant="secondary" />}
        items={[
          { id: "name", label: "Name" },
          { id: "modified", label: "Last modified" },
          { id: "created", label: "Date created" },
          { id: "size", label: "Size" },
        ]}
        selectionMode="single"
        defaultSelectedKeys={["modified"]}
        defaultOpen
      />
    </div>
  );
}

export default App;
```

`selectionMode="single"` behaves like a "sort by" control: picking an option checks it, unchecks any other, and closes the menu to confirm the single choice.

#### Multi Select

ai-kit-menu-selection-mode-multi

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu-selection-mode-multi?file=src/App.tsx)

```tsx lines theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function App() {
  return (
    <div style={{ paddingTop: 16, display: "flex", justifyContent: "center" }}>
      <Menu
        trigger={<Button label="Columns" variant="secondary" />}
        items={[
          { id: "name", label: "Name" },
          { id: "modified", label: "Last modified" },
          { id: "created", label: "Date created" },
          { id: "size", label: "Size" },
        ]}
        selectionMode="multiple"
        defaultSelectedKeys={["name", "size"]}
        defaultOpen
      />
    </div>
  );
}

export default App;
```

`selectionMode="multiple"` lets several options stay checked at once, and the menu stays open after a pick so more can be toggled.

### Action Menu

The hero example above is an action menu — `selectionMode`'s default, `"none"`. Each option runs a command via `onAction` and the menu closes immediately on pick. Action menus and the selection modes render identically at rest; the difference is entirely behavioral — whether picking an option persists a checked state or fires a one-off command.

### Icons

ai-kit-menu-icons

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu-icons?file=src/App.tsx)

```tsx lines theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";
import { IconCopy, IconInbox, IconPencil } from "@servicetitan/anvil2-icons";

function App() {
  return (
    <div style={{ paddingTop: 16, display: "flex", justifyContent: "center" }}>
      <Menu
        trigger={<Button label="Options" variant="secondary" />}
        items={[
          { id: "rename", label: "Rename", icon: <IconPencil /> },
          { id: "duplicate", label: "Duplicate", icon: <IconCopy /> },
          { id: "archive", label: "Move to archive", icon: <IconInbox /> },
        ]}
        defaultOpen
      />
    </div>
  );
}

export default App;
```

An optional leading icon renders at 16px before the label. Icons are decorative and don't contribute to the option's accessible name.

### Disabled Options

ai-kit-menu-disabled-options

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu-disabled-options?file=src/App.tsx)

```tsx lines theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function App() {
  return (
    <div style={{ paddingTop: 16, display: "flex", justifyContent: "center" }}>
      <Menu
        trigger={<Button label="Options" variant="secondary" />}
        selectionMode="multiple"
        defaultSelectedKeys={["archive"]}
        items={[
          { id: "rename", label: "Rename" },
          { id: "duplicate", label: "Duplicate", isDisabled: true },
          { id: "archive", label: "Move to archive", isDisabled: true },
          { id: "delete", label: "Delete" },
        ]}
        defaultOpen
      />
    </div>
  );
}

export default App;
```

A disabled option can't be focused, selected, or activated, and renders dimmed — including an option that is already selected; the dimmed check still shows its state.

### Placement

ai-kit-menu-placement

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu-placement?file=src/App.tsx)

```tsx lines theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function App() {
  return (
    <div
      style={{
        display: "flex",
        justifyContent: "center",
        padding: "160px 0 16px",
      }}
    >
      <Menu
        trigger={<Button label="Options" variant="secondary" />}
        items={[
          { id: "rename", label: "Rename" },
          { id: "duplicate", label: "Duplicate" },
          { id: "delete", label: "Delete" },
        ]}
        placement="top"
        defaultOpen
      />
    </div>
  );
}

export default App;
```

`placement` opens the popover on the `"bottom"` (default, shown in the hero example above) or `"top"` edge of the trigger, with the same 4px gap either way.

## Behavior

The popover hugs its content between a 180px floor and a 360px ceiling, and every option responds to hover, focus, press, and disabled states.

### Visual States

ai-kit-menu-visual-states

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu-visual-states?file=src/App.tsx)

```tsx lines theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function App() {
  return (
    <div style={{ paddingTop: 16, display: "flex", justifyContent: "center" }}>
      <Menu
        trigger={<Button label="Options" variant="secondary" />}
        selectionMode="multiple"
        defaultSelectedKeys={["selected", "selected-disabled"]}
        items={[
          { id: "default", label: "Default" },
          { id: "hover", label: "Hover target" },
          { id: "focus", label: "Focus target" },
          { id: "disabled", label: "Disabled", isDisabled: true },
          { id: "selected", label: "Selected" },
          {
            id: "selected-disabled",
            label: "Selected, disabled",
            isDisabled: true,
          },
        ]}
        defaultOpen
      />
    </div>
  );
}

export default App;
```

Unselected options stay neutral — a subtle gray fill on hover and press, with unchanged text. Selected options use the informative-blue family instead. Keyboard focus draws a ring only, with no background fill. Disabled options render dimmed regardless of selection.

### Wrapping

ai-kit-menu-wrapping

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu-wrapping?file=src/App.tsx)

```tsx lines theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function App() {
  return (
    <div style={{ paddingTop: 16, display: "flex", justifyContent: "center" }}>
      <Menu
        trigger={<Button label="Options" variant="secondary" />}
        items={[
          { id: "short", label: "Rename" },
          {
            id: "long",
            label:
              "Move this item to a different project workspace and notify collaborators",
          },
          { id: "delete", label: "Delete" },
        ]}
        defaultOpen
      />
    </div>
  );
}

export default App;
```

Labels wrap to multiple lines once the popover reaches its 360px max-width, rather than growing the popover without bound.

### Scrolling List

ai-kit-menu-scrolling-list

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu-scrolling-list?file=src/App.tsx)

```tsx lines theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function App() {
  return (
    <div style={{ paddingTop: 16, display: "flex", justifyContent: "center" }}>
      <Menu
        trigger={<Button label="Options" variant="secondary" />}
        items={Array.from({ length: 20 }, (_, index) => ({
          id: `option-${index}`,
          label: `Option ${index + 1}`,
        }))}
        defaultOpen
      />
    </div>
  );
}

export default App;
```

Enough options to exceed the popover's available height scroll internally; the scroll region shares the popover's corner radius so its scrollbar stays clipped to the rounded shape.

## Usage Guidelines

### When to Use

Use the Menu when:

* **Running a command from a trigger** – Each option runs a command or navigates to a destination, and the menu closes on pick.
* **Choosing one option, such as a sort order** – Use single selection. Picking an option checks it and closes the menu.
* **Toggling several options at once** – Use multiple selection. The menu stays open so the user can check more options.

### When not to use

Avoid using the Menu for:

* **Showing options that stay visible on the page** – Use the [Checkbox](https://anvil.servicetitan.com/docs/components/checkbox/ai-kit.md) instead.
* **Listing prior conversations** – Use the [Chat History](https://anvil.servicetitan.com/docs/components/chat-history/web.md) instead.

## Keyboard Interaction

Users can open, navigate, and act on the Menu using standard keyboard controls.

| Key | Description |
| - | - |
| Enter / Space / Down Arrow | Opens the menu from the trigger, focusing the first option |
| Up / Down Arrow | Moves focus between options |
| Typeahead | Jumps focus to the next option starting with the typed character(s) |
| Enter / Space | Activates or toggles the focused option |
| Escape | Closes the menu and returns focus to the trigger |

## Code

**Implementation**

ai-kit-menu

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu?file=src/App.tsx)

```tsx lines expandable theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function App() {
  return (
    <div style={{ paddingTop: 16, display: "flex", justifyContent: "center" }}>
      <Menu
        trigger={<Button label="Options" variant="secondary" />}
        items={[
          { id: "rename", label: "Rename" },
          { id: "duplicate", label: "Duplicate" },
          { id: "archive", label: "Move to archive" },
          { id: "delete", label: "Delete" },
        ]}
        defaultOpen
      />
    </div>
  );
}

export default App;
```

## Common Examples

To use the `Menu`, pass a Carto `Button` through `trigger` and configured `MenuOption` objects through `items`. The kit forbids composed menu-item children.

```tsx theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function ExampleComponent() {
  return (
    <Menu
      trigger={<Button label="Options" variant="secondary" />}
      items={[
        { id: "rename", label: "Rename", onAction: rename },
        { id: "delete", label: "Delete", onAction: remove },
      ]}
    />
  );
}
```

### Selection

The default `selectionMode="none"` creates an action menu that closes after activation. Use `"single"` or `"multiple"` for persistent checked selections.

ai-kit-menu-selection-mode-single

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu-selection-mode-single?file=src/App.tsx)

```tsx lines expandable theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function App() {
  return (
    <div style={{ paddingTop: 16, display: "flex", justifyContent: "center" }}>
      <Menu
        trigger={<Button label="Sort by" variant="secondary" />}
        items={[
          { id: "name", label: "Name" },
          { id: "modified", label: "Last modified" },
          { id: "created", label: "Date created" },
          { id: "size", label: "Size" },
        ]}
        selectionMode="single"
        defaultSelectedKeys={["modified"]}
        defaultOpen
      />
    </div>
  );
}

export default App;
```

ai-kit-menu-selection-mode-multi

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu-selection-mode-multi?file=src/App.tsx)

```tsx lines expandable theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function App() {
  return (
    <div style={{ paddingTop: 16, display: "flex", justifyContent: "center" }}>
      <Menu
        trigger={<Button label="Columns" variant="secondary" />}
        items={[
          { id: "name", label: "Name" },
          { id: "modified", label: "Last modified" },
          { id: "created", label: "Date created" },
          { id: "size", label: "Size" },
        ]}
        selectionMode="multiple"
        defaultSelectedKeys={["name", "size"]}
        defaultOpen
      />
    </div>
  );
}

export default App;
```

### Icons and disabled options

Pass an icon from `@servicetitan/anvil2-icons` through an option's `icon` field. Set `isDisabled` on an option to prevent focus, selection, and activation.

ai-kit-menu-icons

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu-icons?file=src/App.tsx)

```tsx lines expandable theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";
import { IconCopy, IconInbox, IconPencil } from "@servicetitan/anvil2-icons";

function App() {
  return (
    <div style={{ paddingTop: 16, display: "flex", justifyContent: "center" }}>
      <Menu
        trigger={<Button label="Options" variant="secondary" />}
        items={[
          { id: "rename", label: "Rename", icon: <IconPencil /> },
          { id: "duplicate", label: "Duplicate", icon: <IconCopy /> },
          { id: "archive", label: "Move to archive", icon: <IconInbox /> },
        ]}
        defaultOpen
      />
    </div>
  );
}

export default App;
```

ai-kit-menu-disabled-options

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu-disabled-options?file=src/App.tsx)

```tsx lines expandable theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function App() {
  return (
    <div style={{ paddingTop: 16, display: "flex", justifyContent: "center" }}>
      <Menu
        trigger={<Button label="Options" variant="secondary" />}
        selectionMode="multiple"
        defaultSelectedKeys={["archive"]}
        items={[
          { id: "rename", label: "Rename" },
          { id: "duplicate", label: "Duplicate", isDisabled: true },
          { id: "archive", label: "Move to archive", isDisabled: true },
          { id: "delete", label: "Delete" },
        ]}
        defaultOpen
      />
    </div>
  );
}

export default App;
```

### Link options

Pass `href` to render an option as a navigation link. An option with `target="_blank"` receives a safe `rel` default and a localized new-tab announcement.

### Placement and long lists

`placement` opens the popover above or below its trigger with a fixed 4px gap. Long labels wrap at the 360px width ceiling, and long lists scroll within available viewport height.

ai-kit-menu-placement

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu-placement?file=src/App.tsx)

```tsx lines expandable theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function App() {
  return (
    <div
      style={{
        display: "flex",
        justifyContent: "center",
        padding: "160px 0 16px",
      }}
    >
      <Menu
        trigger={<Button label="Options" variant="secondary" />}
        items={[
          { id: "rename", label: "Rename" },
          { id: "duplicate", label: "Duplicate" },
          { id: "delete", label: "Delete" },
        ]}
        placement="top"
        defaultOpen
      />
    </div>
  );
}

export default App;
```

ai-kit-menu-scrolling-list

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-menu-scrolling-list?file=src/App.tsx)

```tsx lines expandable theme={null}
import { Button, Menu } from "@servicetitan/anvil2-ai-kit";

function App() {
  return (
    <div style={{ paddingTop: 16, display: "flex", justifyContent: "center" }}>
      <Menu
        trigger={<Button label="Options" variant="secondary" />}
        items={Array.from({ length: 20 }, (_, index) => ({
          id: `option-${index}`,
          label: `Option ${index + 1}`,
        }))}
        defaultOpen
      />
    </div>
  );
}

export default App;
```

## Internationalization

Menu option labels are consumer content and must be localized before they are passed in `items`. External link options use `ai-kit.menu.opensInNewTab` for the hidden new-tab announcement.

```tsx theme={null}
<AiKitIntlProvider
  messages={{
    "ai-kit.menu.opensInNewTab": "(opens in a separate tab)",
  }}
>
  <Menu
    trigger={<Button label="Help" />}
    items={[
      {
        id: "docs",
        label: "Documentation",
        href: "https://example.com",
        target: "_blank",
      },
    ]}
  />
</AiKitIntlProvider>
```

## React Accessibility

* React Aria supplies keyboard navigation, typeahead, focus management, and selection semantics.
* Arrow keys move between options. Enter or Space activates the focused option. Escape closes the menu and restores focus to the trigger.
* External link options include a localized hidden new-tab announcement.

**Menu Props**

```tsx theme={null}
<Menu
  trigger={<Button label="Options" />}
  items={[{ id: "rename", label: "Rename" }]}
  placement="bottom"
  selectionMode="none"
/>
```

## `Menu` Props

The `Menu` accepts the following props and forwards remaining React Aria `MenuTrigger` and `Menu` props except their composed children and render slots.

**items** `MenuOption[]` *required*

Options to render from top to bottom.

**trigger** `ReactElement\<ButtonProps\>` *required*

Carto `Button` that opens the menu.

**className** `string`

Class merged onto the popover surface.

**defaultOpen** `boolean`

Whether the menu starts open when uncontrolled.

**isOpen** `boolean`

Controlled open state. Pair with `onOpenChange`.

**onOpenChange** `(isOpen: boolean) =\> void`

Called when the menu's open state changes.

**onSelectionChange** `(keys: Selection) =\> void`

Called when selected option keys change.

**placement** `"top" | "bottom"` (default: bottom)

Side where the popover opens relative to its trigger.

**selectedKeys** `Selection`

Controlled selected option keys.

**selectionMode** `"none" | "single" | "multiple"` (default: none)

`"none"` creates an action menu. `"single"` and `"multiple"` persist
checked selection.

**MenuOption**

```tsx theme={null}
const option: MenuOption = {
  id: "rename",
  label: "Rename",
  icon: <IconPencil />,
  onAction: rename,
};
```

## `MenuOption`

`MenuOption` extends React Aria `MenuItem` props with configured content.

**id** `Key` *required*

Selection key reported through the menu's selection APIs.

**label** `string` *required*

Visible label and accessible name.

**href** `Href`

Destination that renders the option as a navigation link.

**icon** `IconElement`

Leading icon rendered before the label.

**isDisabled** `boolean`

Prevents focus, selection, and activation.

**onAction** `() =\> void`

Handles activation for an action option.

**target** `string`

Link target. `"_blank"` adds the localized new-tab announcement.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.