Latticecomponents

Context Menu

Pointer-anchored action menu that opens at the right-click position, owning open state, popper placement, and layered dismissal while you own the visuals.

@lattice-ui/react-context-menuFeature limitedimport ContextMenudepends on runtime, layer, motion, popper

Context Menu is the primitive for actions that belong to a specific object rather than to a button: right-click a slot, a plot, a player name, and get a menu at the pointer. It differs from Menu in one decisive way — the menu is anchored to where you clicked, not to the element you clicked. The trigger is a region, not a button.

Reach for Context Menu when the action list belongs to a region of the screen, when the menu should appear under the pointer, and when a secondary (right) click is the natural way to ask for it. Reach for Menu instead when a visible button opens the list, or when gamepad and keyboard users must be able to move through the items.

The component running live in the browser — the same @rbxts/react tree Roblox renders, fully interactive. Right-click inside the card to open the menu at the pointer.

Edit

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 matter of swapping the namespace.

Context Menu anatomy

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>
PartRequiredResponsibility
ContextMenu.RootyesOwns open state and the pointer position the menu opens at.
ContextMenu.TriggeryesThe region that listens for a secondary click and reports where it happened.
ContextMenu.PortalyesRenders the surface into a ScreenGui outside the local tree.
ContextMenu.ContentyesThe pointer-anchored, dismissable surface.
ContextMenu.ItemyesA clickable action that emits onSelect and closes the menu.
ContextMenu.GroupnoA container that groups related items. Supply the layout yourself.
ContextMenu.LabelnoA non-interactive heading for a group or section.
ContextMenu.SeparatornoA thin divider between items or groups.

Examples

Basic context menu

An uncontrolled root, a trigger region, and a few items. Right-clicking anywhere 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 color for the trigger, a layout and surface for the content, and a size plus label for each item. Nothing here is optional decoration — without it the menu opens and works, but draws nothing.

BasicContextMenu.tsx
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>
);
}

A real trigger region

asChild merges the secondary-click listener onto your own element, which is the normal way to use this primitive: the trigger is the thing the actions belong to. The child keeps all of its own props and handlers — the slot only adds InputBegan and Active — so a left-click Activated on the same element still does whatever it did before.

PlotContextMenu.tsx
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 muted, non-interactive heading above them, and Separator draws a 1px divider. None of them are interactive — they exist to give a long list structure.

InventorySlotContextMenu.tsx
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. That is what makes the default placement="bottom" drop the menu’s top-left corner at the cursor, the way a desktop context menu behaves. Change placement when you want it to grow the other way — near the bottom of the screen the popper flips it for you regardless.

MinimapContextMenu.tsx
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 for you but does not paint it. useContextMenuItemContext() returns { highlighted, disabled }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:

ContextMenuRow.tsx
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 now yours, you also choose whether it animates. Wrap the transparency in a response motion if you want the old eased feel.

Disabled items and staying open

disabled on an item blocks activation and clears its highlight state. onSelect receives a ContextMenuSelectEvent; calling event.preventDefault() marks it default-prevented and the item skips the automatic close — the one thing the default behavior does — so the menu stays open. That makes toggle-style items possible.

MarkerContextMenu.tsx
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 the menu needs to close it — a round ending, a selection being cleared, a different panel taking over. The trigger still owns where the menu appears, so controlling open does not mean you have to supply a position.

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

ContextMenu.Root is controllable on open/onOpenChange, with defaultOpen for uncontrolled usage (defaulting to closed). ContextMenu.Trigger watches InputBegan and reacts only to Enum.UserInputType.MouseButton2: it converts the raw pointer position into the inset-adjusted space that GuiObject.AbsolutePosition uses, stores it on the root, and opens the menu. That stored position survives until the next secondary click, so a controlled root can reopen the menu at the same spot.

A disabled trigger ignores the secondary click entirely and never updates the stored position.

Positioning

ContextMenu.Content mounts an invisible virtual anchor at the stored pointer position and hands it to the shared popper machinery. The anchor has zero height and the measured content’s width, which is what makes the resolved placement land the menu’s top-left corner at the cursor instead of centering it under the pointer.

From there it behaves like every other anchored surface: the requested placement (default "bottom") is a preference, and on collision the popper tries the opposite side, then the orthogonal sides, then clamps the best candidate inside the viewport with collisionPadding (default 8) kept from every edge. sideOffset and alignOffset shift the result. Until the first measurement completes the content is parked off-screen, so it never flashes at the wrong position.

Items and activation

ContextMenu.Item activates on Activated and builds a ContextMenuSelectEvent ({ defaultPrevented, preventDefault() }) for onSelect; if the event is not default-prevented, the item closes the menu. A disabled item ignores activation.

The item renders an unstyled textbutton. It tracks hover through MouseEnter/MouseLeave and exposes the result as useContextMenuItemContext().highlighted, but draws nothing itself — rendering the highlight is yours, under asChild or not.

Dismissal

ContextMenu.Content participates in dismissable-layer behavior: only the top-most enabled layer receives outside interactions, so nested overlays dismiss one at a time. Context Menu is modal by default, so 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

ContextMenu.Content runs no motion of its own. Pass a transition to animate it; createPopperEntranceRecipe(placement) from @lattice-ui/react-motion matches the frame the content renders, and taking the resolved placement makes the motion originate from the side the menu actually landed on — so a menu the popper flipped above the pointer animates from above. forceMount keeps the content mounted through its exit. The content wrapper is an automatically-sized frame, so your surface defines the measured size.

API reference

ContextMenu.Root

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, blocks pointer interaction behind the surface with a full-screen blocker. Defaults to true.
childrenReact.ReactNodeThe context menu parts.

ContextMenu.Trigger

PropTypeDescription
asChildbooleanMerge the secondary-click listener onto the single child element instead of the textbutton the part renders.
disabledbooleanIgnores the secondary click, so the menu never opens from this region.
childrenReact.ReactElementThe element to render. Required when asChild is set.

ContextMenu.Portal

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.

ContextMenu.Content

PropTypeDescription
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".
sideOffsetnumberGap in pixels between the pointer anchor and the content. Defaults to 0.
alignOffsetnumberShift in pixels along the anchor'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 frame instead of the part's own children.
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.

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.

PropTypeDescription
asChildbooleanMerge item behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button.
disabledbooleanPrevents activation and forces highlighted to false.
onSelect(event: ContextMenuSelectEvent) => voidCalled on activation. Call event.preventDefault() to keep the menu open.
childrenReact.ReactNodeThe item contents. Must be a single element when asChild is set.
…TextButton propsPartial<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.

PropTypeDescription
asChildbooleanMerge the group onto the single child element instead of the frame the part renders.
childrenReact.ReactNodeThe grouped items, plus the layout that arranges them. Must be a single element when asChild is set.
…Frame propsPartial<WritableInstanceProperties<Frame>>Forwarded onto the rendered frame and type-checked against it.

ContextMenu.Label

PropTypeDescription
asChildbooleanMerge the label onto the single child element instead of the textlabel the part renders.
childrenReact.ReactNodeThe label contents. Must be a single element when asChild is set.
…TextLabel propsPartial<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

PropTypeDescription
asChildbooleanMerge the separator onto the single child element instead of the frame the part renders.
childrenReact.ReactNodeRendered inside the separator. Must be a single element when asChild is set.
…Frame propsPartial<WritableInstanceProperties<Frame>>Forwarded onto the rendered frame and type-checked against it. Give it a Size and BackgroundColor3 — it draws nothing on its own.