@lattice-ui/react-context-menuFeature limitedimport ContextMenudepends on runtime, layer, motion, popperContext Menu is for actions belonging to a specific object rather than a button: right-click a slot, a plot, a player name. It differs from Menu in one way — it anchors to where you clicked, not to the element you clicked, so the trigger is a region. Use Menu when a visible button opens the list, or when gamepad and keyboard users must move through the items.
Context Menu opens on MouseButton2 and tracks item hover through useContextMenuItemContext. Its items do not register with the focus manager — no automatic focus, no Up/Down movement, no focus restore. On a gamepad- or keyboard-first surface, use Menu.
Right-click inside the card to open the menu at the pointer.
import { ContextMenu } from "@lattice-ui/react-context-menu";import { React } from "@lattice-ui/react-runtime";import { mergeGuiProps, Text, useTheme } from "@lattice-ui/react-style";import { panelRecipe } from "../../../playground/src/client/theme/recipes";
const actions = ["Rename", "Duplicate", "Move to…"];
function ContextMenuExample() { const { theme } = useTheme();
return ( <ContextMenu.Root> <ContextMenu.Trigger asChild> <textbutton {...(mergeGuiProps(panelRecipe({ tone: "elevated" }, theme), { AutoButtonColor: false, Size: UDim2.fromOffset(320, 180), Text: "", }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.lg)} /> <uistroke Color={theme.colors.border} Thickness={1} />
<Text AnchorPoint={new Vector2(0.5, 0.5)} BackgroundTransparency={1} Font={Enum.Font.GothamBold} Position={UDim2.fromScale(0.5, 0.42)} Size={UDim2.fromOffset(280, 20)} Text="Project card" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.bodyMd.textSize} /> <Text AnchorPoint={new Vector2(0.5, 0.5)} BackgroundTransparency={1} Position={UDim2.fromScale(0.5, 0.58)} Size={UDim2.fromOffset(280, 16)} Text="Right-click anywhere inside this card" TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} /> </textbutton> </ContextMenu.Trigger>
<ContextMenu.Portal> <ContextMenu.Content asChild> <frame {...(mergeGuiProps(panelRecipe({ tone: "elevated" }, theme), { AutomaticSize: Enum.AutomaticSize.Y, Size: UDim2.fromOffset(200, 0), }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingBottom={new UDim(0, theme.space[6])} PaddingLeft={new UDim(0, theme.space[6])} PaddingRight={new UDim(0, theme.space[6])} PaddingTop={new UDim(0, theme.space[6])} /> <uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, theme.space[2])} />
<ContextMenu.Label asChild> <Text BackgroundTransparency={1} LayoutOrder={0} Size={UDim2.fromOffset(188, 24)} Text="Card" TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} > <uipadding PaddingLeft={new UDim(0, theme.space[8])} /> </Text> </ContextMenu.Label>
{actions.map((action, index) => ( // Items stay BackgroundTransparency=1: the primitive drives // BackgroundColor3 between fixed dark hover colors, which would // fight the light theme. See the Select scene for the same trade. <ContextMenu.Item asChild key={action}> <textbutton AutoButtonColor={false} BackgroundTransparency={1} BorderSizePixel={0} LayoutOrder={index + 1} Size={UDim2.fromOffset(188, 32)} Text={action} TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} > <uicorner CornerRadius={new UDim(0, theme.radius.sm)} /> <uipadding PaddingLeft={new UDim(0, theme.space[8])} /> </textbutton> </ContextMenu.Item> ))}
<ContextMenu.Separator asChild> <frame BackgroundColor3={theme.colors.border} BorderSizePixel={0} LayoutOrder={actions.size() + 1} Size={UDim2.fromOffset(188, 1)} /> </ContextMenu.Separator>
<ContextMenu.Item asChild> <textbutton AutoButtonColor={false} BackgroundTransparency={1} BorderSizePixel={0} LayoutOrder={actions.size() + 2} Size={UDim2.fromOffset(188, 32)} Text="Delete" TextColor3={theme.colors.danger} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} > <uicorner CornerRadius={new UDim(0, theme.radius.sm)} /> <uipadding PaddingLeft={new UDim(0, theme.space[8])} /> </textbutton> </ContextMenu.Item> </frame> </ContextMenu.Content> </ContextMenu.Portal> </ContextMenu.Root> );}Import
import { ContextMenu } from "@lattice-ui/react-context-menu";Anatomy
Root, Trigger, Portal and Content form the working menu. Item makes it useful, and
Group, Label and Separator structure longer lists. The part names match Menu’s, so moving
between the two is mostly a namespace swap.
Context Menu anatomy
<ContextMenu.Root> <ContextMenu.Trigger /> <ContextMenu.Portal> <ContextMenu.Content> <ContextMenu.Label /> <ContextMenu.Group> <ContextMenu.Item /> </ContextMenu.Group> <ContextMenu.Separator /> <ContextMenu.Item /> </ContextMenu.Content> </ContextMenu.Portal></ContextMenu.Root>| Part | Required | Responsibility |
|---|---|---|
ContextMenu.Root | yes | Owns open state and the pointer position the menu opens at. |
ContextMenu.Trigger | yes | The region that listens for a secondary click and reports where it happened. |
ContextMenu.Portal | yes | Renders the surface into a ScreenGui outside the local tree. |
ContextMenu.Content | yes | The pointer-anchored, dismissable surface. |
ContextMenu.Item | yes | A clickable action that emits onSelect and closes the menu. |
ContextMenu.Group | no | A container that groups related items. Supply the layout yourself. |
ContextMenu.Label | no | A non-interactive heading for a group or section. |
ContextMenu.Separator | no | A thin divider between items or groups. |
Examples
Basic context menu
An uncontrolled root, a trigger region and a few items. Right-clicking inside the trigger opens the
menu at the pointer. Selecting an item runs its onSelect and closes it.
Every part renders unstyled, so this example supplies all of it. A size and colour for the trigger, a layout and surface for the content, and a size and label for each item. Without it the menu opens and works but draws nothing.
import { ContextMenu } from "@lattice-ui/react-context-menu";
const ITEMS = [ { label: "Rename", action: () => print("rename") }, { label: "Duplicate", action: () => print("duplicate") }, { label: "Delete", action: () => print("delete") },];
export function BasicContextMenu() { return ( <ContextMenu.Root> <ContextMenu.Trigger BackgroundColor3={Color3.fromRGB(32, 36, 46)} Size={UDim2.fromOffset(280, 160)} Text="Right-click here" TextColor3={Color3.fromRGB(150, 158, 176)} />
<ContextMenu.Portal> <ContextMenu.Content BackgroundColor3={Color3.fromRGB(28, 31, 40)}> <uicorner CornerRadius={new UDim(0, 8)} /> <uipadding PaddingBottom={new UDim(0, 4)} PaddingTop={new UDim(0, 4)} /> <uilistlayout FillDirection={Enum.FillDirection.Vertical} />
{ITEMS.map((item) => ( <ContextMenu.Item key={item.label} onSelect={item.action} Size={UDim2.fromOffset(220, 34)} Text={item.label} TextColor3={Color3.fromRGB(236, 241, 249)} TextXAlignment={Enum.TextXAlignment.Left} > <uipadding PaddingLeft={new UDim(0, 10)} /> </ContextMenu.Item> ))} </ContextMenu.Content> </ContextMenu.Portal> </ContextMenu.Root> );}ContextMenu.Item follows the pointer but paints no highlight. Read the state with useContextMenuItemContext() and render it yourself — see Highlighting items.
Omit open/onOpenChange and the root owns its state from defaultOpen. The pointer position is always owned by the root — even controlling open yourself, the anchor comes from the last secondary click on the trigger.
A real trigger region
asChild merges the secondary-click listener onto your own element, which is the normal way to use
this primitive. The child keeps its own props and handlers, since the slot only adds InputBegan
and Active. A left-click Activated still works.
import { ContextMenu } from "@lattice-ui/react-context-menu";
export function PlotContextMenu(props: { plotName: string; onSelect: () => void }) { return ( <ContextMenu.Root> <ContextMenu.Trigger asChild> <textbutton Size={UDim2.fromOffset(320, 200)} Text={props.plotName} Event={{ Activated: props.onSelect }} /> </ContextMenu.Trigger>
<ContextMenu.Portal> <ContextMenu.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} /> <uicorner CornerRadius={new UDim(0, 8)} />
<ContextMenu.Item onSelect={() => print("build")}> <textbutton Text="Build here" /> </ContextMenu.Item> <ContextMenu.Item onSelect={() => print("clear")}> <textbutton Text="Clear plot" /> </ContextMenu.Item> </frame> </ContextMenu.Content> </ContextMenu.Portal> </ContextMenu.Root> );}Groups, labels, and separators
Group wraps related items in a 220-wide vertical-layout frame, Label puts a non-interactive
heading above them, Separator draws a 1px divider. None are interactive.
import { ContextMenu } from "@lattice-ui/react-context-menu";
export function InventorySlotContextMenu(props: { icon: string }) { return ( <ContextMenu.Root> <ContextMenu.Trigger asChild> <imagebutton Image={props.icon} Size={UDim2.fromOffset(64, 64)} /> </ContextMenu.Trigger>
<ContextMenu.Portal> <ContextMenu.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)} />
<ContextMenu.Label asChild> <textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(220, 24)} Text="Equipment" TextColor3={Color3.fromRGB(162, 173, 191)} /> </ContextMenu.Label> <ContextMenu.Group> <ContextMenu.Item onSelect={() => print("equip")}> <textbutton Text="Equip" /> </ContextMenu.Item> <ContextMenu.Item onSelect={() => print("inspect")}> <textbutton Text="Inspect" /> </ContextMenu.Item> </ContextMenu.Group>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={() => print("drop")}> <textbutton Text="Drop" TextColor3={Color3.fromRGB(244, 120, 120)} /> </ContextMenu.Item> </frame> </ContextMenu.Content> </ContextMenu.Portal> </ContextMenu.Root> );}Placement tuning
ContextMenu.Content takes the same popper options as the other anchored primitives. But the anchor
is a zero-height virtual frame at the pointer, as wide as the measured content, which drops the
menu’s top-left corner at the cursor. placement grows it the other way, and near the bottom of the
screen the popper flips it regardless.
import { ContextMenu } from "@lattice-ui/react-context-menu";
export function MinimapContextMenu() { return ( <ContextMenu.Root> <ContextMenu.Trigger asChild> <imagebutton Image="rbxassetid://0" Size={UDim2.fromOffset(180, 180)} /> </ContextMenu.Trigger>
<ContextMenu.Portal> <ContextMenu.Content placement="bottom" sideOffset={2} collisionPadding={16}> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundColor3={Color3.fromRGB(28, 32, 42)} Size={UDim2.fromOffset(190, 0)} > <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} />
<ContextMenu.Item onSelect={() => print("ping")}> <textbutton Text="Ping location" /> </ContextMenu.Item> <ContextMenu.Item onSelect={() => print("waypoint")}> <textbutton Text="Set waypoint" /> </ContextMenu.Item> </frame> </ContextMenu.Content> </ContextMenu.Portal> </ContextMenu.Root> );}Highlighting items
The item tracks hover but does not paint it. useContextMenuItemContext() returns
{ highlighted, disabled }, and highlighted is already false while disabled, so one branch covers
both.
Read it from a component rendered inside the item, since that is where the context lives:
import { ContextMenu, useContextMenuItemContext } from "@lattice-ui/react-context-menu";
function RowSurface(props: { label: string }) { const { highlighted, disabled } = useContextMenuItemContext();
return ( <frame BackgroundColor3={Color3.fromRGB(64, 84, 138)} BackgroundTransparency={highlighted ? 0 : 1} BorderSizePixel={0} Size={UDim2.fromScale(1, 1)} > <uicorner CornerRadius={new UDim(0, 4)} /> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text={props.label} TextColor3={disabled ? Color3.fromRGB(110, 116, 132) : Color3.fromRGB(236, 241, 249)} TextXAlignment={Enum.TextXAlignment.Left} /> </frame> );}
export function ContextMenuRow(props: { label: string; disabled?: boolean }) { return ( <ContextMenu.Item disabled={props.disabled} Size={UDim2.fromOffset(220, 34)}> <RowSurface label={props.label} /> </ContextMenu.Item> );}Because the highlight is yours, you also choose whether it animates.
Disabled items and staying open
disabled on an item blocks activation and clears its highlight state. onSelect receives a
ContextMenuSelectEvent, and event.preventDefault() skips the automatic close so the menu stays
open — which is what makes toggle-style items possible.
import { useState } from "@rbxts/react";import { ContextMenu } from "@lattice-ui/react-context-menu";
export function MarkerContextMenu(props: { canDelete: boolean }) { const [pinned, setPinned] = useState(false);
return ( <ContextMenu.Root> <ContextMenu.Trigger asChild> <textbutton Size={UDim2.fromOffset(240, 120)} Text="Marker" /> </ContextMenu.Trigger>
<ContextMenu.Portal> <ContextMenu.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} />
{/* Stays open so you can see the state flip. */} <ContextMenu.Item onSelect={(event) => { event.preventDefault(); setPinned(!pinned); }} > <textbutton Text={pinned ? "Unpin" : "Pin"} /> </ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item disabled={!props.canDelete} onSelect={() => print("delete")}> <textbutton Text="Delete" /> </ContextMenu.Item> </frame> </ContextMenu.Content> </ContextMenu.Portal> </ContextMenu.Root> );}Controlled open state
Pass open and onOpenChange when something outside needs to close the menu — a round ending, a
panel taking over. The trigger still owns where it appears, so controlling open does not mean
supplying a position.
import { useEffect, useState } from "@rbxts/react";import { ContextMenu } from "@lattice-ui/react-context-menu";
export function ControlledContextMenu(props: { editable: boolean }) { const [open, setOpen] = useState(false);
// Leaving edit mode should take the menu with it. useEffect(() => { if (!props.editable) { setOpen(false); } }, [props.editable]);
return ( <ContextMenu.Root open={open} onOpenChange={setOpen}> <ContextMenu.Trigger disabled={!props.editable} asChild> <textbutton Size={UDim2.fromOffset(320, 200)} Text="Canvas" /> </ContextMenu.Trigger>
<ContextMenu.Portal> <ContextMenu.Content> <ContextMenu.Item onSelect={() => print("cut")} /> <ContextMenu.Item onSelect={() => print("paste")} /> </ContextMenu.Content> </ContextMenu.Portal> </ContextMenu.Root> );}How it behaves
Open state and the anchor
Controllable on open/onOpenChange, with defaultOpen for uncontrolled usage. Trigger watches
InputBegan for MouseButton2 only, converts the pointer position into the inset-adjusted space
AbsolutePosition uses, stores it on the root and opens the menu. That position survives until the
next secondary click.
A disabled trigger ignores the secondary click entirely and never updates the stored position.
Positioning
The content mounts an invisible virtual anchor at the stored pointer position, with zero height and the measured content’s width. That lands the menu’s top-left corner at the cursor, rather than centring it.
From there it behaves like every other anchored surface. placement is a preference, and on
collision the popper tries the opposite side, then the orthogonal ones, then clamps inside the
viewport. Until the first measurement, the content is parked off-screen.
Items and activation
ContextMenu.Item activates on Activated and builds a ContextMenuSelectEvent for onSelect,
carrying defaultPrevented and preventDefault(). Unless the event is default-prevented, the item
closes the menu. A disabled item ignores activation.
The item renders an unstyled textbutton, tracking hover through MouseEnter/MouseLeave and
exposing it as useContextMenuItemContext().highlighted while drawing nothing itself.
Dismissal
Only the top-most enabled layer receives outside interactions, so nested overlays dismiss one at a
time. Context Menu is modal by default: a full-screen blocker stops interaction behind it, and
an outside press dismisses. onPointerDownOutside and onInteractOutside fire first with a
LayerInteractEvent, and preventDefault() in either vetoes the dismissal.
Motion and presence
The content runs no motion of its own. Pass a transition. createPopperEntranceRecipe(placement)
matches the frame it renders, and taking the resolved placement makes the motion originate
from the side the menu actually landed on. forceMount keeps it mounted through its exit. The
wrapper is an automatically-sized frame, so your surface defines the measured size.
ContextMenu.Item registers no focus node: no automatic focus on open, no Up/Down movement, no focus restore. modal here means “blocks pointer interaction behind the surface”, not “traps selection”. For a surface operable without a mouse, use Menu.
Before 0.7.0 ContextMenu.Item animated your element’s BackgroundColor3 between fixed hover colors with no opt-out. It no longer touches color at all — it only reports highlighted through useContextMenuItemContext(). Set the background yourself and it will stay put.
API reference
ContextMenu.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, blocks pointer interaction behind the surface with a full-screen blocker. Defaults to true. |
| children | React.ReactNode | The context menu parts. |
ContextMenu.Trigger
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge the secondary-click listener onto the single child element instead of the textbutton the part renders. |
| disabled | boolean | Ignores the secondary click, so the menu never opens from this region. |
| children | React.ReactElement | The element to render. Required when asChild is set. |
ContextMenu.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. |
ContextMenu.Content
| Prop | Type | Description |
|---|---|---|
| placement | "top" | "bottom" | "left" | "right" | Requested side to position the content on, relative to the pointer anchor. Falls back to the opposite side, then the orthogonal sides, on collision. Defaults to "bottom". |
| sideOffset | number | Gap in pixels between the pointer anchor and the content. Defaults to 0. |
| alignOffset | number | Shift in pixels along the anchor'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 frame instead of the part's own children. |
| 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. |
ContextMenu.Item
Renders a TextButton. Unknown props forward onto it and are type-checked against it, so a prop TextButton does not accept is a compile error. The primitive owns Active and Selectable derived from disabled, so values you pass for those are ignored.
| 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 activation and forces highlighted to false. |
| onSelect | (event: ContextMenuSelectEvent) => void | Called on activation. Call event.preventDefault() to keep the menu open. |
| children | React.ReactNode | The item contents. Must be a single element when asChild is set. |
| …TextButton props | Partial<WritableInstanceProperties<TextButton>> | Forwarded onto the rendered textbutton and type-checked against it. Active and Selectable are owned by the primitive, derived from disabled. |
ContextMenu.Group
Renders a Frame. Unknown props forward onto it and are type-checked against it, so a prop Frame does not accept is a compile error.
As of 0.7.0 ContextMenu.Group renders a plain frame: no UIListLayout, no AutomaticSize. It was one of the last two primitives holding an opinion about how children lay out. Add a uilistlayout child, as Select.Group and Combobox.Group have always required.
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge the group onto the single child element instead of the frame the part renders. |
| children | React.ReactNode | The grouped items, plus the layout that arranges them. Must be a single element when asChild is set. |
| …Frame props | Partial<WritableInstanceProperties<Frame>> | Forwarded onto the rendered frame and type-checked against it. |
ContextMenu.Label
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge the label onto the single child element instead of the textlabel the part renders. |
| children | React.ReactNode | The label contents. Must be a single element when asChild is set. |
| …TextLabel props | Partial<WritableInstanceProperties<TextLabel>> | Forwarded onto the rendered textlabel and type-checked against it. Pass Text and TextColor3 here — the part renders no copy of its own. |
ContextMenu.Separator
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge the separator onto the single child element instead of the frame the part renders. |
| children | React.ReactNode | Rendered inside the separator. Must be a single element when asChild is set. |
| …Frame props | Partial<WritableInstanceProperties<Frame>> | Forwarded onto the rendered frame and type-checked against it. Give it a Size and BackgroundColor3 — it draws nothing on its own. |