Latticecomponents

Menu

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

@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: 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 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

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.

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

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

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.

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.

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.

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.

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.