Latticecomponents

Context Menu

A pointer-anchored action menu that opens at the right-click position.

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

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

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

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

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 child keeps its own props and handlers, since the slot only adds InputBegan and Active. A left-click Activated still works.

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 non-interactive heading above them, Separator draws a 1px divider. None are interactive.

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

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

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

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

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

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.

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.