# Menu

> Anchored action-menu primitive that owns open state, ordered selection movement, popper positioning, and layered dismissal while you own the visuals.

Source: https://docs.astra-void.xyz/lattice-ui/components/menu/

`@lattice-ui/react-menu` · Stable direction · import `Menu` · depends on `runtime`, `focus`, `layer`, `motion`, `popper`

Menu is the primitive for a list of actions that opens from a trigger: context menus, dropdown actions, overflow menus, and command lists. It coordinates open state, ordered item movement, positioning, dismissal, and exit motion so your component only has to render the items and their contents.

Reach for Menu when you need a surface of **selectable actions** that **moves selection in order** (gamepad up/down and arrow keys), **anchors** to its trigger through the popper foundation, and **dismisses on selection or outside interaction**. Menu is **modal by default** — selection is trapped inside the open menu and restored to the trigger on close.

## Import

```ts
import { Menu } from "@lattice-ui/react-menu";
```

## Anatomy

Compose the parts you need. `Root`, `Trigger`, `Portal`, and `Content` form the working menu; `Item` makes it useful, and `Group`, `Label`, and `Separator` structure longer lists.

```tsx title="Menu anatomy"
<Menu.Root>
  <Menu.Trigger />
  <Menu.Portal>
    <Menu.Content>
      <Menu.Label />
      <Menu.Group>
        <Menu.Item />
      </Menu.Group>
      <Menu.Separator />
      <Menu.Item />
    </Menu.Content>
  </Menu.Portal>
</Menu.Root>
```

| Part | Required | Responsibility |
| --- | --- | --- |
| `Menu.Root` | yes | Owns open state, the item registry, and selection movement. |
| `Menu.Trigger` | yes | A button that toggles the menu and acts as the positioning anchor and focus-restore target. |
| `Menu.Portal` | yes | Renders the surface into a `ScreenGui` outside the local tree. |
| `Menu.Content` | yes | The positioned, focus-trapped, dismissable, motion-driven surface. |
| `Menu.Item` | yes | A selectable action that registers for ordered movement and emits `onSelect`. |
| `Menu.Group` | no | A container that groups related items. Supply the layout yourself. |
| `Menu.Label` | no | A non-interactive heading for a group or section. |
| `Menu.Separator` | no | A thin divider between items or groups. |

## Examples

### Basic actions menu

The smallest useful menu: an uncontrolled root, a trigger, and a few items. Every part here renders its default element — the trigger is a `textbutton` labeled `"Toggle Menu"`, and each item is a left-aligned 220x34 `textbutton` that highlights on hover and gamepad selection — so you can wire up actions before styling anything. Selecting an item runs its `onSelect` and closes the menu.

```tsx title="BasicActionsMenu.tsx"
import { Menu } from "@lattice-ui/react-menu";

export function BasicActionsMenu() {
  return (
    <Menu.Root>
      <Menu.Trigger />

      <Menu.Portal>
        <Menu.Content>
          <Menu.Item onSelect={() => print("rename")} />
          <Menu.Item onSelect={() => print("duplicate")} />
          <Menu.Item onSelect={() => print("delete")} />
        </Menu.Content>
      </Menu.Portal>
    </Menu.Root>
  );
}
```

> **Uncontrolled by default**
>
> Omit `open`/`onOpenChange` and the root owns its state, starting from `defaultOpen` (closed by default). Reach for controlled state only when something outside the menu needs to open or close it — see [Controlled right-click menu](#controlled-right-click-menu).

### Groups, labels, and separators

`Group` wraps related items, `Label` puts a non-interactive heading above them, and `Separator` marks a division. None of them register with selection movement — Up/Down skips straight from the last item of one group to the first enabled item of the next.

All three render unstyled. As of 0.7.0 `Menu.Group` no longer supplies a vertical `UIListLayout` or forces `AutomaticSize` — it was one of the last two primitives holding an opinion about how children lay out, so give it a layout of its own. `Label` renders no copy, and `Separator` draws nothing until you size and color it.

```tsx title="InventoryItemMenu.tsx"
import { Menu } from "@lattice-ui/react-menu";

export function InventoryItemMenu(props: { itemName: string }) {
  return (
    <Menu.Root>
      <Menu.Trigger asChild>
        <textbutton Text={props.itemName} Size={UDim2.fromOffset(160, 36)} />
      </Menu.Trigger>

      <Menu.Portal>
        <Menu.Content>
          <frame
            AutomaticSize={Enum.AutomaticSize.Y}
            BackgroundColor3={Color3.fromRGB(28, 32, 42)}
            Size={UDim2.fromOffset(220, 0)}
          >
            <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} />
            <uicorner CornerRadius={new UDim(0, 8)} />

            <Menu.Label asChild>
              <textlabel
                BackgroundTransparency={1}
                Size={UDim2.fromOffset(220, 24)}
                Text="Equipment"
                TextColor3={Color3.fromRGB(162, 173, 191)}
              />
            </Menu.Label>
            <Menu.Group AutomaticSize={Enum.AutomaticSize.Y} Size={UDim2.fromOffset(220, 0)}>
              <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} />

              <Menu.Item onSelect={() => print("equip")}>
                <textbutton Text="Equip" />
              </Menu.Item>
              <Menu.Item onSelect={() => print("inspect")}>
                <textbutton Text="Inspect" />
              </Menu.Item>
            </Menu.Group>

            <Menu.Separator
              BackgroundColor3={Color3.fromRGB(58, 64, 80)}
              Size={UDim2.new(1, 0, 0, 1)}
            />

            <Menu.Item onSelect={() => print("drop")}>
              <textbutton Text="Drop" TextColor3={Color3.fromRGB(244, 120, 120)} />
            </Menu.Item>
          </frame>
        </Menu.Content>
      </Menu.Portal>
    </Menu.Root>
  );
}
```

### Icon items with asChild

`asChild` on `Item` merges the selection behavior — activation, Up/Down movement, hover and gamepad highlight — onto your own single element through the shared `Slot`. The slot's `Active`, `Selectable`, and ref win over the child's own props, and event handlers compose. Use a `textbutton` or `imagebutton` so `Activated` fires. The primitive tracks the highlight but never paints it — read `useMenuItemContext().highlighted` and render it yourself (see [How it behaves](#items-and-activation)).

```tsx title="IconActionsMenu.tsx"
import { Menu } from "@lattice-ui/react-menu";

const ACTIONS = [
  { id: "trade", label: "Trade", icon: "rbxassetid://1234567890" },
  { id: "invite", label: "Invite to party", icon: "rbxassetid://1234567891" },
  { id: "block", label: "Block", icon: "rbxassetid://1234567892" },
];

export function IconActionsMenu(props: { onAction: (id: string) => void }) {
  return (
    <Menu.Root>
      <Menu.Trigger asChild>
        <imagebutton Image="rbxassetid://1234567893" Size={UDim2.fromOffset(32, 32)} />
      </Menu.Trigger>

      <Menu.Portal>
        <Menu.Content>
          <frame
            AutomaticSize={Enum.AutomaticSize.Y}
            BackgroundColor3={Color3.fromRGB(28, 32, 42)}
            Size={UDim2.fromOffset(200, 0)}
          >
            <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} />

            {ACTIONS.map((action) => (
              <Menu.Item key={action.id} onSelect={() => props.onAction(action.id)} asChild>
                <textbutton AutoButtonColor={false} Size={UDim2.fromOffset(200, 34)} Text="">
                  <uipadding PaddingLeft={new UDim(0, 10)} />
                  <uilistlayout
                    FillDirection={Enum.FillDirection.Horizontal}
                    Padding={new UDim(0, 8)}
                    VerticalAlignment={Enum.VerticalAlignment.Center}
                  />
                  <imagelabel
                    BackgroundTransparency={1}
                    Image={action.icon}
                    Size={UDim2.fromOffset(16, 16)}
                  />
                  <textlabel
                    BackgroundTransparency={1}
                    Size={UDim2.fromOffset(160, 34)}
                    Text={action.label}
                    TextColor3={Color3.fromRGB(234, 239, 247)}
                    TextXAlignment={Enum.TextXAlignment.Left}
                  />
                </textbutton>
              </Menu.Item>
            ))}
          </frame>
        </Menu.Content>
      </Menu.Portal>
    </Menu.Root>
  );
}
```

### Controlled right-click menu

Pass `open` and `onOpenChange` when something other than the trigger's own activation should open the menu — here a right-click (or long-press-style secondary input) on an inventory slot. The trigger still has to exist because it is the positioning anchor and the focus-restore target; with `asChild` its toggle behavior composes with your slot's own handlers, so a left-click `Activated` also toggles as usual.

> **Anchored to the slot, not the pointer**
>
> This opens the menu **against the trigger**, which is what you want when the slot is small and the menu should hug it. If you want the menu to appear **at the cursor** instead, that is [Context Menu](https://docs.astra-void.xyz/lattice-ui/components/context-menu.md) — it anchors to the click position and needs no controlled state to do so. The trade-off is that Context Menu is pointer-only: it has no ordered gamepad or keyboard movement.

```tsx title="SlotContextMenu.tsx"
import { useState } from "@rbxts/react";
import { Menu } from "@lattice-ui/react-menu";

export function SlotContextMenu(props: { slotIcon: string; onAction: (id: string) => void }) {
  const [open, setOpen] = useState(false);

  return (
    <Menu.Root open={open} onOpenChange={setOpen}>
      <Menu.Trigger asChild>
        <imagebutton
          Image={props.slotIcon}
          Size={UDim2.fromOffset(64, 64)}
          Event={{
            MouseButton2Click: () => setOpen(true),
          }}
        />
      </Menu.Trigger>

      <Menu.Portal>
        <Menu.Content placement="right" sideOffset={4}>
          <frame
            AutomaticSize={Enum.AutomaticSize.Y}
            BackgroundColor3={Color3.fromRGB(28, 32, 42)}
            Size={UDim2.fromOffset(180, 0)}
          >
            <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} />

            <Menu.Item onSelect={() => props.onAction("use")}>
              <textbutton Text="Use" />
            </Menu.Item>
            <Menu.Item onSelect={() => props.onAction("split")}>
              <textbutton Text="Split stack" />
            </Menu.Item>
            <Menu.Item onSelect={() => props.onAction("drop")}>
              <textbutton Text="Drop" />
            </Menu.Item>
          </frame>
        </Menu.Content>
      </Menu.Portal>
    </Menu.Root>
  );
}
```

### Placement tuning

`Menu.Content` accepts the popper positioning options. `placement` requests a side (`"top" | "bottom" | "left" | "right"`, default `"bottom"`), `sideOffset` adds a pixel gap between the trigger and the content, `alignOffset` shifts the content along the trigger's cross axis, and `collisionPadding` sets the minimum distance kept from the screen edge (default `8`). The requested side is a preference, not a guarantee — when it would overflow, the popper tries the opposite side, then the two orthogonal sides, and finally clamps the best candidate inside the viewport.

```tsx title="SidebarOverflowMenu.tsx"
import { Menu } from "@lattice-ui/react-menu";

export function SidebarOverflowMenu() {
  return (
    <Menu.Root>
      <Menu.Trigger asChild>
        <textbutton Text="More" Size={UDim2.fromOffset(96, 32)} />
      </Menu.Trigger>

      <Menu.Portal>
        <Menu.Content placement="right" sideOffset={8} alignOffset={-4} collisionPadding={16}>
          <frame
            AutomaticSize={Enum.AutomaticSize.Y}
            BackgroundColor3={Color3.fromRGB(28, 32, 42)}
            Size={UDim2.fromOffset(200, 0)}
          >
            <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} />

            <Menu.Item onSelect={() => print("settings")}>
              <textbutton Text="Settings" />
            </Menu.Item>
            <Menu.Item onSelect={() => print("help")}>
              <textbutton Text="Help" />
            </Menu.Item>
          </frame>
        </Menu.Content>
      </Menu.Portal>
    </Menu.Root>
  );
}
```

### Disabled items and staying open

`disabled` on an item blocks activation, removes it from gamepad selection, and skips it during Up/Down movement. `onSelect` receives a `MenuSelectEvent`; calling `event.preventDefault()` marks it default-prevented, and the item then skips the automatic close — the one thing the default behavior does — so the menu stays open. That makes toggle-style items possible, like a filter list you can flip several times in one visit.

```tsx title="LootFilterMenu.tsx"
import { useState } from "@rbxts/react";
import { Menu } from "@lattice-ui/react-menu";

const RARITIES = ["Common", "Rare", "Epic"];

export function LootFilterMenu(props: { hasLoot: boolean }) {
  const [enabled, setEnabled] = useState<Record<string, boolean>>({ Common: true, Rare: true, Epic: true });

  return (
    <Menu.Root>
      <Menu.Trigger asChild>
        <textbutton Text="Filters" Size={UDim2.fromOffset(120, 36)} />
      </Menu.Trigger>

      <Menu.Portal>
        <Menu.Content>
          <frame
            AutomaticSize={Enum.AutomaticSize.Y}
            BackgroundColor3={Color3.fromRGB(28, 32, 42)}
            Size={UDim2.fromOffset(220, 0)}
          >
            <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} />

            {RARITIES.map((rarity) => (
              <Menu.Item
                key={rarity}
                onSelect={(event) => {
                  event.preventDefault();
                  setEnabled({ ...enabled, [rarity]: !enabled[rarity] });
                }}
              >
                <textbutton Text={`${enabled[rarity] ? "[x]" : "[ ]"} ${rarity}`} />
              </Menu.Item>
            ))}

            <Menu.Separator />

            <Menu.Item disabled={!props.hasLoot} onSelect={() => print("collect all")}>
              <textbutton Text="Collect all" />
            </Menu.Item>
          </frame>
        </Menu.Content>
      </Menu.Portal>
    </Menu.Root>
  );
}
```

## How it behaves

### Open state

`Menu.Root` is controllable on `open`/`onOpenChange`, with `defaultOpen` for uncontrolled usage (defaulting to closed). `Menu.Trigger` toggles the open state on `Activated` and on the `Return`/`Space` keys, focusing itself first when it is about to open the menu so focus restoration has a stable target. Selecting an item closes the menu unless the item's `onSelect` calls `preventDefault`.

### Positioning

`Menu.Content` is positioned by the popper foundation, anchored to the trigger. It measures the trigger and the content, then evaluates candidate placements in order — the requested side, its opposite, then the two orthogonal sides (which carry a small penalty so they are only chosen when both primary sides overflow) — and picks the first perfect fit or the least-overflowing candidate, clamped inside the viewport with `collisionPadding` kept from every edge. Until the first measurement completes the content is parked off-screen, so it never flashes at the wrong position.

Tune the result with `placement` (default `"bottom"`), `sideOffset` (gap from the trigger, default `0`), `alignOffset` (shift along the cross axis, default `0`), and `collisionPadding` (default `8`).

### Focus and ordered movement

When the menu opens, the first enabled item is focused automatically and selection is **trapped** inside the content (Menu is modal by default). `Menu.Item` registers itself with the root in render order, and that registry drives ordered movement: pressing `Up`/`Down` on a focused item moves selection to the previous or next available item. Disabled, invisible, and non-selectable items are skipped. Movement stops at the ends of the list — it does not wrap around. When the menu closes, focus is restored to the trigger.

### Items and activation

`Menu.Item` activates on click/tap (`Activated`) and on the `Return`/`Space` keys. Activation builds a `MenuSelectEvent` (`{ defaultPrevented, preventDefault() }`) and passes it to `onSelect`; if the event is not default-prevented, the item closes the menu. A disabled item ignores activation and movement keys entirely.

`Menu.Item` renders an unstyled `textbutton`. It tracks whether the item is highlighted — by hover **or** by managed keyboard/gamepad focus — and exposes that as `useMenuItemContext().highlighted`, but draws nothing itself. Reporting focus as well as hover matters: an item that never becomes the engine's `SelectedObject` still highlights correctly, and moving the pointer away no longer clears the highlight on the item the keyboard has focused.

### Dismissal

`Menu.Content` participates in dismissable-layer behavior: only the top-most enabled layer receives outside interactions, so nested overlays dismiss one at a time. Because Menu is modal, a full-screen blocker stops interaction behind the surface, and an outside press dismisses the menu. Before dismissal, `onPointerDownOutside` fires for outside pointer presses and `onInteractOutside` fires for the interaction in general; both receive a `LayerInteractEvent` (`{ originalEvent, defaultPrevented, preventDefault() }`), and calling `preventDefault()` in either handler vetoes the dismissal while still letting you observe the interaction.

### Motion and presence

`Menu.Content` runs no motion of its own. Pass a `transition` to animate it — `createPopperEntranceRecipe(placement)` matches the `frame` the content renders, and building it from the **resolved** placement makes a menu the popper flipped above the trigger animate from above. `forceMount` keeps the content mounted through its exit (useful when you drive motion yourself or need the node to persist). The content wrapper is an automatically-sized `frame`, so your surface defines the measured size.

> **Menu is modal**
>
> Menu defaults to `modal={true}`: it traps selection inside the open content and blocks interaction behind it. Set `modal={false}` on `Menu.Root` for a lightweight, non-blocking menu that leaves the rest of the UI interactive — closer to Popover's default behavior.

> **The primitive drives the item highlight**
>
> Before 0.7.0 `Menu.Item` animated your element's `BackgroundColor3` between fixed colors with no opt-out. It no longer touches color — it only reports `highlighted` through `useMenuItemContext()`. A background you set stays put, and you decide whether the highlight animates.

> **Selection movement does not wrap**
>
> `Up` on the first item and `Down` on the last item keep selection where it is instead of cycling to the other end. Order items so the most common actions sit at the top, where selection starts.

## API reference

### Menu.Root

| Prop | Type | Description |
| --- | --- | --- |
| `open` | `boolean` | Controlled open state. Pair with onOpenChange. |
| `defaultOpen` | `boolean` | Initial open state for uncontrolled usage. Defaults to false. |
| `onOpenChange` | `(open: boolean) => void` | Called whenever the open state changes. |
| `modal` | `boolean` | When true, traps selection inside the menu and blocks interaction behind it with a full-screen blocker. Defaults to true. |
| `children` | `React.ReactNode` | The menu parts. |

### Menu.Trigger

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge the toggle behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. |
| `disabled` | `boolean` | Prevents the trigger from toggling the menu and removes it from gamepad selection. |
| `children` | `React.ReactElement` | The element to render. Required when asChild is set. |

### Menu.Portal

| Prop | Type | Description |
| --- | --- | --- |
| `container` | `BasePlayerGui` | Target PlayerGui to render the surface into. Defaults to the surrounding portal context's container. |
| `displayOrderBase` | `number` | Base DisplayOrder for the generated ScreenGui, used to order it against other layers. Defaults to the surrounding portal context's value. |
| `children` | `React.ReactNode` | The content part. |

### Menu.Content

| Prop | Type | Description |
| --- | --- | --- |
| `placement` | `"top" \| "bottom" \| "left" \| "right"` | Requested side to position the content on. Falls back to the opposite side, then the orthogonal sides, on collision. Defaults to "bottom". |
| `sideOffset` | `number` | Gap in pixels between the trigger and the content. Defaults to 0. |
| `alignOffset` | `number` | Shift in pixels along the trigger's cross axis. Defaults to 0. |
| `collisionPadding` | `number` | Minimum distance in pixels to keep from every screen edge when resolving and clamping placement. Defaults to 8. |
| `asChild` | `boolean` | Render the single child element inside the positioned wrapper instead of the frame the part renders. |
| `forceMount` | `boolean` | Keeps the content mounted while exit motion runs, instead of unmounting on close. |
| `transition` | `PresenceMotionConfig` | Reveal/exit motion. None by default; pass createPopperEntranceRecipe(placement) for a placement-aware entrance. |
| `onPointerDownOutside` | `(event: LayerInteractEvent) => void` | Called when a pointer press occurs outside the content, before dismissal. Call event.preventDefault() to veto the dismissal. |
| `onInteractOutside` | `(event: LayerInteractEvent) => void` | Called for any outside interaction, before dismissal. Call event.preventDefault() to veto the dismissal. |
| `children` | `React.ReactNode` | The menu contents. |

### Menu.Item

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge item behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. |
| `disabled` | `boolean` | Prevents selection, removes the item from gamepad selection, and skips it during ordered movement. |
| `onSelect` | `(event: MenuSelectEvent) => void` | Called on activation. Call event.preventDefault() to keep the menu open. |
| `children` | `React.ReactElement` | The element to render. Required when asChild is set. |

### Menu.Group

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge the group onto the single child element instead of the frame the part renders. Since 0.7.0 the group has no layout of its own — supply a uilistlayout. |
| `children` | `React.ReactElement` | The grouped items to render. Required when asChild is set. |

### Menu.Label

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge the label onto the single child element instead of the textlabel the part renders. Pass Text — the part renders no copy of its own. |
| `children` | `React.ReactElement` | The label element to render. Required when asChild is set. |

### Menu.Separator

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge the separator onto the single child element instead of the frame the part renders. Give it a Size and BackgroundColor3 — it draws nothing on its own. |
| `children` | `React.ReactElement` | The divider element to render. Required when asChild is set. |

## Related

- [Context Menu](https://docs.astra-void.xyz/lattice-ui/components/context-menu.md)
- [Positioning with popper](https://docs.astra-void.xyz/lattice-ui/guides/positioning-with-popper.md)
- [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md)
- [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md)
- [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md)
- [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md)
