@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: dropdowns, overflow menus, command lists. It coordinates open state, ordered item movement, positioning, dismissal and exit motion, and anchors to the trigger through the popper foundation. Menu is modal by default — selection is trapped inside it and restored to the trigger on close.
Import
import { Menu } from "@lattice-ui/react-menu";Anatomy
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
An uncontrolled root, a trigger, and a few items. 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 from defaultOpen. Reach for controlled state only when something outside the menu drives it.
Groups, labels, and separators
Group wraps related items, Label is a non-interactive heading, Separator marks a division.
None register with selection movement, so Up/Down skips from the last item of one group to the first
enabled item of the next.
All three render unstyled: give Group a layout of its own, Label its copy, and Separator a size and colour.
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 activation, Up/Down movement and the highlight onto your own element. The
slot’s Active, Selectable and ref win over the child’s, and event handlers compose — use a
textbutton or imagebutton so Activated fires. The primitive tracks the highlight but never
paints it: read useMenuItemContext().highlighted.
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 activation opens the menu.
The trigger still has to exist — it is the positioning anchor and the focus-restore target, and
under asChild its toggle behaviour composes with your handlers.
This opens the menu against the trigger. For one at the cursor, use Context Menu, which anchors to the click position and needs no controlled state. It is pointer-only, with 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 options. placement requests a side, sideOffset a gap from the
trigger, alignOffset a shift along the cross axis, and collisionPadding a minimum distance from
the screen edge. The requested side is a preference: on overflow the popper tries the opposite side,
then the orthogonal ones, then clamps.
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 blocks activation, removes the item from gamepad selection and skips it during Up/Down
movement. onSelect receives a MenuSelectEvent, and event.preventDefault() skips the automatic
close — which is what makes toggle-style items possible.
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.
Menu.Trigger toggles on Activated and on Return/Space, focusing itself first when it is
about to open so focus restoration has a stable target. Selecting an item closes the menu unless
onSelect calls preventDefault.
Positioning
Menu.Content is positioned by the popper foundation, anchored to the trigger. It measures both,
then evaluates candidate placements in order — the requested side, its opposite, then the two
orthogonal sides, which carry a small penalty. It picks the first perfect fit, or the
least-overflowing candidate, clamped inside the viewport with collisionPadding from every edge.
Until the first measurement completes the content is parked off-screen.
Tune it with placement (default "bottom"), sideOffset (default 0), alignOffset (default
0) and collisionPadding (default 8).
Focus and ordered movement
When the menu opens, the first enabled item is focused and selection is trapped inside the
content. Menu.Item registers with the root in render order, and that registry drives Up/Down
movement, skipping disabled, invisible and non-selectable items. Movement stops at the ends rather
than wrapping. On close, focus is restored to the trigger.
Items and activation
Menu.Item activates on Activated and on Return/Space, building a MenuSelectEvent for
onSelect with defaultPrevented and preventDefault(). Unless the event is default-prevented,
the item closes the menu. A disabled item ignores activation and movement keys.
Menu.Item renders an unstyled textbutton. It tracks whether the item is highlighted, by hover
or by managed keyboard and gamepad focus, and exposes that as useMenuItemContext().highlighted
while drawing nothing itself. Reporting focus as well as hover keeps an item highlighted when it
never becomes the engine’s SelectedObject, and stops a pointer moving away from clearing a
keyboard-focused item.
Dismissal
Menu.Content participates in dismissable-layer behaviour. Only the top-most enabled layer receives
outside interactions, so nested overlays dismiss one at a time. Being modal, a full-screen blocker
stops interaction behind it, and an outside press dismisses. Before dismissal,
onPointerDownOutside fires for outside pointer presses and onInteractOutside for the interaction
in general. Both receive a LayerInteractEvent, and preventDefault() in either vetoes the
dismissal.
Motion and presence
Menu.Content runs no motion of its own. Pass a transition.
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. The wrapper is an
automatically-sized frame, so your surface defines the measured size.
Menu defaults to modal={true}, trapping selection inside the open content and blocking interaction behind it. modal={false} gives a non-blocking menu that leaves the rest of the UI interactive.
Menu.Item never touches colour — 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 keep selection where it is. 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. |