> ## 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 – Design

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

export const LiveCode = ({children, customHeight, clickToLoad, example, fullWidth, fullHeight, hideCodeInLiveCode, screenshot, screenshotOnly, showCode: showCodeProp}) => {
  const SCREENSHOTS_BASE = "https://servicetitan.github.io/anvil2-docs-live-code/screenshots";
  const STACKBLITZ_BASE = "https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples";
  const [showCodeBlock, setShowCodeBlock] = useState(showCodeProp ?? false);
  const [isLocalOverride, setIsLocalOverride] = useState(false);
  useEffect(() => {
    const examplePath = `/images/live-code-screenshots-tmp/${example}.png`;
    fetch(examplePath, {
      method: "HEAD"
    }).then(r => {
      if (r.ok) setIsLocalOverride(true);
    }).catch(() => {});
  }, [example]);
  const screenshotBase = isLocalOverride ? "/images/live-code-screenshots-tmp" : SCREENSHOTS_BASE;
  if (screenshotOnly) {
    return <Frame className="flex flex-col">
        <div className="flex dark:hidden" style={{
      justifyContent: "center",
      alignItems: "center",
      width: fullWidth ? "100%" : "50%",
      minHeight: fullHeight ? "284px" : undefined,
      background: "#FFFFFF"
    }}>
          <img srcset={`${screenshotBase}/${example}.png, ${screenshotBase}/${example}-2x.png 2x`} src={`${screenshotBase}/${example}.png`} alt={example} noZoom />
        </div>
        <div className="hidden dark:flex" style={{
      justifyContent: "center",
      alignItems: "center",
      width: fullWidth ? "100%" : "50%",
      minHeight: fullHeight ? "284px" : undefined,
      background: "#141414"
    }}>
          <img srcset={`${screenshotBase}/${example}-dark.png, ${screenshotBase}/${example}-dark-2x.png 2x`} src={`${screenshotBase}/${example}-dark.png`} alt={example} noZoom />
        </div>
      </Frame>;
  }
  if (screenshot) {
    return <Frame className="flex flex-col -mb-2">
        <div className="flex dark:hidden bg-white dark:bg-codeblock border border-gray-950/10 dark:border-white/10 dark:twoslash-dark rounded-2xl overflow-hidden" style={{
      justifyContent: "center",
      alignItems: "center",
      width: fullWidth ? "100%" : "50%",
      minHeight: fullHeight ? "284px" : undefined
    }}>
          <img srcset={`${screenshotBase}/${example}.png, ${screenshotBase}/${example}-2x.png 2x`} src={`${screenshotBase}/${example}.png`} alt={example} noZoom />
        </div>

        <div className="hidden dark:flex bg-white dark:bg-codeblock border border-gray-950/10 dark:border-white/10 dark:twoslash-dark rounded-2xl overflow-hidden" style={{
      background: "#141414",
      justifyContent: "center",
      alignItems: "center",
      width: fullWidth ? "100%" : "50%",
      minHeight: fullHeight ? "284px" : undefined
    }}>
          <img srcset={`${screenshotBase}/${example}-dark.png, ${screenshotBase}/${example}-dark-2x.png 2x`} src={`${screenshotBase}/${example}-dark.png`} alt={example} noZoom />
        </div>

        <div className="flex justify-end items-center text-xs py-2 px-1 gap-4">
          {!showCodeProp ? <button className="inline-flex justify-end items-center text-gray-700 dark:text-gray-50 hover:text-blue-500 dark:hover:text-blue-300 transition-colors group self-end gap-1 cursor-pointer" onClick={() => setShowCodeBlock(!showCodeBlock)} style={{
      appearance: "none"
    }}>
              <Icon icon="code" size="12px" className="group-hover:bg-blue-500 dark:group-hover:bg-blue-300" />
              <span>{showCodeBlock ? "Hide code" : "Show code"}</span>
            </button> : null}

          <a className="inline-flex justify-end items-center hover:text-blue-500 dark:hover:text-blue-300 transition-colors group self-end gap-1" href={`${STACKBLITZ_BASE}/${example}?file=src/App.tsx`} target="_blank" rel="noreferrer">
            <Icon icon="bolt" size="12px" className="group-hover:bg-blue-500 dark:group-hover:bg-blue-300" />
            <span>StackBlitz demo</span>
          </a>
        </div>

        <div className="grid transition-[grid-template-rows] duration-300 ease-in-out overflow-auto overflow-y-hidden overflow-x-auto" style={showCodeBlock ? {
      gridTemplateRows: "1fr"
    } : {
      gridTemplateRows: "0fr"
    }}>
          <div style={{
      minHeight: 0,
      overflowX: "auto",
      overflowY: "hidden",
      marginBlockStart: "-1.25rem",
      marginBlockEnd: "-1.5rem"
    }}>
            {children}
          </div>
        </div>
      </Frame>;
  } else {
    return <div style={{
      display: "flex",
      width: fullWidth ? "100%" : "50%",
      minHeight: customHeight ? customHeight : "316px",
      resize: "vertical",
      overflow: "auto"
    }}>
        <iframe title={example} style={{
      flex: 1,
      width: fullWidth ? "100%" : "50%",
      minHeight: customHeight ? customHeight : "316px"
    }} src={`${STACKBLITZ_BASE}/${example}?embed=1&hideNavigation=1&hideExplorer=1&terminalHeight=0&file=src/App.tsx${clickToLoad ? "&ctl=1" : ""}${hideCodeInLiveCode ? "&view=preview" : ""}`} allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking" sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts" />
      </div>;
  }
};

export const CodePreviewPlaceholder = ({double, fullWidth}) => {
  const single = <div style={{
    width: fullWidth ? "100%" : "50%",
    borderRadius: "1rem",
    display: "flex",
    padding: "1rem",
    flexDirection: "column",
    gap: "0.5rem",
    height: "10rem",
    marginBlockEnd: "1rem"
  }} className="border-width-default border-color-subdued">
      <div className="bg-strong border-radius-large" style={{
    width: "100%",
    flexGrow: "1"
  }} />
      <div className="bg-strong border-radius-large" style={{
    width: "100%",
    flexGrow: "1"
  }} />
    </div>;
  return double ? <div style={{
    display: "flex",
    gap: "1rem"
  }}>
      {single}
      {single}
    </div> : single;
};

<LiveCode example="carto-menu" screenshot fullWidth>
  ```tsx lines theme={null}
  import { Button, Menu } from "@servicetitan/carto-react-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;
  ```
</LiveCode>

## 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

<LiveCode example="carto-menu-selection-mode-single" screenshot fullWidth>
  ```tsx lines theme={null}
  import { Button, Menu } from "@servicetitan/carto-react-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;
  ```
</LiveCode>

`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

<LiveCode example="carto-menu-selection-mode-multi" screenshot fullWidth>
  ```tsx lines theme={null}
  import { Button, Menu } from "@servicetitan/carto-react-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;
  ```
</LiveCode>

`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

<LiveCode example="carto-menu-icons" screenshot fullWidth>
  ```tsx lines theme={null}
  import { Button, Menu } from "@servicetitan/carto-react-kit";
  import {
    IconCopy,
    IconInbox,
    IconPencil,
  } from "@servicetitan/carto-react-kit/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;
  ```
</LiveCode>

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

<LiveCode example="carto-menu-disabled-options" screenshot fullWidth>
  ```tsx lines theme={null}
  import { Button, Menu } from "@servicetitan/carto-react-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;
  ```
</LiveCode>

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

<LiveCode example="carto-menu-placement" screenshot fullWidth>
  ```tsx lines theme={null}
  import { Button, Menu } from "@servicetitan/carto-react-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;
  ```
</LiveCode>

`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

<LiveCode example="carto-menu-visual-states" screenshot fullWidth>
  ```tsx lines theme={null}
  import { Button, Menu } from "@servicetitan/carto-react-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;
  ```
</LiveCode>

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

<LiveCode example="carto-menu-wrapping" screenshot fullWidth>
  ```tsx lines theme={null}
  import { Button, Menu } from "@servicetitan/carto-react-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;
  ```
</LiveCode>

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

### Scrolling List

<LiveCode example="carto-menu-scrolling-list" screenshot fullWidth>
  ```tsx lines theme={null}
  import { Button, Menu } from "@servicetitan/carto-react-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;
  ```
</LiveCode>

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.

## 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                    |
