Latticecomponents

Menu

An anchored action menu with ordered selection movement and layered dismissal.

@lattice-ui/react-menuStable directionimport Menudepends on runtime, focus, layer, motion, popper

Menu 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 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>
PartRequiredResponsibility
Menu.RootyesOwns open state, the item registry, and selection movement.
Menu.TriggeryesA button that toggles the menu and acts as the positioning anchor and focus-restore target.
Menu.PortalyesRenders the surface into a ScreenGui outside the local tree.
Menu.ContentyesThe positioned, focus-trapped, dismissable, motion-driven surface.
Menu.ItemyesA selectable action that registers for ordered movement and emits onSelect.
Menu.GroupnoA container that groups related items. Supply the layout yourself.
Menu.LabelnoA non-interactive heading for a group or section.
Menu.SeparatornoA 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.

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>
);
}

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.

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

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

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

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

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

API reference

PropTypeDescription
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanInitial open state for uncontrolled usage. Defaults to false.
onOpenChange(open: boolean) => voidCalled whenever the open state changes.
modalbooleanWhen true, traps selection inside the menu and blocks interaction behind it with a full-screen blocker. Defaults to true.
childrenReact.ReactNodeThe menu parts.
PropTypeDescription
asChildbooleanMerge the toggle behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button.
disabledbooleanPrevents the trigger from toggling the menu and removes it from gamepad selection.
childrenReact.ReactElementThe element to render. Required when asChild is set.
PropTypeDescription
containerBasePlayerGuiTarget PlayerGui to render the surface into. Defaults to the surrounding portal context's container.
displayOrderBasenumberBase DisplayOrder for the generated ScreenGui, used to order it against other layers. Defaults to the surrounding portal context's value.
childrenReact.ReactNodeThe content part.
PropTypeDescription
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".
sideOffsetnumberGap in pixels between the trigger and the content. Defaults to 0.
alignOffsetnumberShift in pixels along the trigger's cross axis. Defaults to 0.
collisionPaddingnumberMinimum distance in pixels to keep from every screen edge when resolving and clamping placement. Defaults to 8.
asChildbooleanRender the single child element inside the positioned wrapper instead of the frame the part renders.
forceMountbooleanKeeps the content mounted while exit motion runs, instead of unmounting on close.
transitionPresenceMotionConfigReveal/exit motion. None by default; pass createPopperEntranceRecipe(placement) for a placement-aware entrance.
onPointerDownOutside(event: LayerInteractEvent) => voidCalled when a pointer press occurs outside the content, before dismissal. Call event.preventDefault() to veto the dismissal.
onInteractOutside(event: LayerInteractEvent) => voidCalled for any outside interaction, before dismissal. Call event.preventDefault() to veto the dismissal.
childrenReact.ReactNodeThe menu contents.
PropTypeDescription
asChildbooleanMerge item behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button.
disabledbooleanPrevents selection, removes the item from gamepad selection, and skips it during ordered movement.
onSelect(event: MenuSelectEvent) => voidCalled on activation. Call event.preventDefault() to keep the menu open.
childrenReact.ReactElementThe element to render. Required when asChild is set.
PropTypeDescription
asChildbooleanMerge 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.
childrenReact.ReactElementThe grouped items to render. Required when asChild is set.
PropTypeDescription
asChildbooleanMerge the label onto the single child element instead of the textlabel the part renders. Pass Text — the part renders no copy of its own.
childrenReact.ReactElementThe label element to render. Required when asChild is set.
PropTypeDescription
asChildbooleanMerge 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.
childrenReact.ReactElementThe divider element to render. Required when asChild is set.