@lattice-ui/react-menuStable directionimport Menudepends on runtime, focus, layer, motion, popperMenu 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
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.
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.
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> );}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.
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.
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).
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.
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 — 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.
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.
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.
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 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.
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.
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. |