Latticeguides

Gamepad and input

One input model for pointer, touch, keyboard and gamepad players.

A Roblox experience is played with whatever is in the player’s hands, often several devices in one session. Lattice UI primitives are built so one composition serves all of them. Pointer and touch activate controls directly, keyboard arrows move through items in a deterministic order, and gamepad navigation rides Roblox’s selection engine. This page covers how movement and activation reach your components. For scopes, trapping and restore, see Focus management.

One item, three input classes

Every interactive lattice item wires the same three Roblox event surfaces. A tab trigger, a radio item, a menu item:

  • Activated — fires on mouse click, touch tap and gamepad A: the press path for pointers and gamepads.
  • InputBegan — receives keyboard input while the item holds selection, which is where arrow movement and Enter/Space activation are handled.
  • SelectionGained / SelectionLost — fire when GuiService.SelectedObject lands on or leaves the item, whether the engine moved it or lattice did. Selection visuals and selection-follows-focus live here.
The event wiring every lattice item uses
const eventHandlers = React.useMemo(
() => ({
Activated: handleActivated, // click, tap, gamepad A
InputBegan: handleInputBegan, // arrows, Enter, Space while selected
SelectionGained: handleSelectionGained, // selection arrived here
}),
[handleActivated, handleInputBegan, handleSelectionGained],
);
return <textbutton Active={!disabled} Selectable={!disabled} Event={eventHandlers} ref={setItemRef} />;

The ordered-selection model

Composite widgets need “next item” to mean something stable, not “whatever is geometrically nearby”. Each item registers an ordered-selection entry with its root — { id, order, ref, getDisabled?, getVisible? } — and the root resolves movement with the helpers from @lattice-ui/react-focus:

  • getOrderedSelectionEntries sorts by the order field, so movement order is declaration order, not render or array order.
  • An entry is available only if its GuiObject exists, is not disabled, is Visible, and has Selectable set. Unavailable entries are skipped.
  • getRelativeOrderedSelectionEntry(entries, currentId, direction) steps through the available entries and clamps at the ends rather than wrapping. With no current entry, +1 resolves the first available item and -1 the last.
  • focusOrderedSelectionEntry(entry) hands the result to the focus manager.

This is exactly what Tabs, RadioGroup, and Menu do internally. Their roots keep a registry. Each item reports movement, and the root focuses the resolved neighbour:

How Menu resolves Up/Down (from MenuRoot)
const moveSelection = React.useCallback((direction: -1 | 1) => {
const currentItem = getCurrentOrderedSelectionEntry(itemEntriesRef.current);
const nextItem = getRelativeOrderedSelectionEntry(itemEntriesRef.current, currentItem?.id, direction);
focusOrderedSelectionEntry(nextItem);
}, []);

Gamepad selection

On gamepad, lattice does not intercept the thumbstick or d-pad. Movement is Roblox’s own directional navigation: the engine walks GuiService.SelectedObject between Selectable objects by on-screen geometry and draws its default selection ring. lattice-ui sets no SelectionGroup and replaces no SelectionImageObject. What it does instead:

  • Primitives keep Selectable truthful. Every item renders with Selectable={!disabled}, so the engine only lands on things your logic considers interactive.
  • The focus bridge reads movement back. While any FocusScope is active, the manager listens to GuiService.SelectedObject changes and updates the model to match. It pulls selection back inside if a trapped scope is active and it escaped. See Focus management.
  • SelectionGained drives your visuals and state. Tabs and RadioGroup select their value the moment selection lands on an item. Menu highlights it with the same handler it uses for MouseEnter.
Selection visuals ride SelectionGained (MenuItem pattern)
const handlePointerEnter = React.useCallback(() => setActive(true), []);
const handlePointerLeave = React.useCallback(() => setActive(false), []);
const eventHandlers = React.useMemo(
() => ({
Activated: handleActivated,
MouseEnter: handlePointerEnter,
MouseLeave: handlePointerLeave,
SelectionGained: handlePointerEnter, // gamepad focus looks like hover
SelectionLost: handlePointerLeave,
}),
[handleActivated, handlePointerEnter, handlePointerLeave],
);

Activation is the engine’s job too: A on the selected object fires Activated, the same handler a click or tap runs. You write no gamepad-specific activation code.

Keyboard

Keyboard input reaches the item that currently holds selection through InputBegan. Each composite maps arrow KeyCodes to an ordered-selection move, and Enter (Return) or Space to activation:

ComponentMovement keysActivation
Tabs.Trigger←/→ when orientation="horizontal", ↑/↓ when verticalEnter, Space — and selecting a trigger activates it
RadioGroup.Item←/→ or ↑/↓ per orientation (default vertical)Enter, Space — moving also selects the landed item
Menu.Item↑/↓Enter, Space

Unlike gamepad movement, keyboard movement goes through the ordered-selection helpers. It follows declaration order, skips disabled and hidden items, and clamps at the ends rather than wrapping. Menu.Trigger also opens on Enter/Space, after which the menu focuses its first available item so arrows work immediately.

Pointer and touch

Pointers need the one thing selection does not: dismissal by pressing elsewhere. @lattice-ui/react-layer’s dismissable stack listens to UserInputService.InputBegan and treats exactly two input types as pointers — MouseButton1 and Touch. A press outside the topmost enabled layer’s content fires onPointerDownOutside and onInteractOutside, then dismisses the layer unless a handler calls preventDefault(). The hit test uses GetGuiObjectsAtPosition with inset-compensated sample points. Input the engine already consumed (gameProcessedEvent) is ignored, and only the topmost layer reacts — nested surfaces dismiss one at a time.

Keeping a surface open on outside press
<Popover.Content
onPointerDownOutside={(event) => {
// e.g. presses on the anchor toolbar should not dismiss
event.preventDefault();
}}
/>

Touch has no hover, so do not gate anything important behind MouseEnter alone. The SelectionGained-as-hover pattern above means gamepad players get the highlight, and touch players see state change on tap. For how layers stack and where portalled surfaces live, see Portals and layers.

What disabled actually does

Disabling an item does not unregister anything — it flips live getters that every path checks at use time:

  • The rendered textbutton gets Selectable={false} and Active={false}, so the engine’s gamepad navigation skips it and it stops firing Activated.
  • The item’s focus node reports getDisabled() === true, so the focus manager refuses to resolve it. It cannot be focused imperatively, used as a trap fallback, or restored to.
  • Its ordered-selection entry becomes unavailable, so keyboard movement steps over it as if it were not there.
  • The item’s own handlers early-return, so stray input while it disables mid-frame does nothing.

Keep these in agreement in your own composites: Selectable is what Roblox enforces, getDisabled is what the model enforces. The Roblox UI constraints guide covers what goes wrong when they diverge.

Example: a gamepad-friendly radio group

Everything above composes for free — this settings group is fully drivable by click, tap, arrows, and gamepad. The only input-specific work left to you is sizing. Give each row enough height to be a comfortable touch target and a legible selection-ring stop. 36–44 px works well.

QualityPicker.tsx
import { React } from "@rbxts/react";
import { RadioGroup } from "@lattice-ui/react-radio-group";
const OPTIONS = ["low", "medium", "high", "ultra"];
export function QualityPicker(props: { value: string; onChange: (value: string) => void }) {
return (
<RadioGroup.Root value={props.value} onValueChange={props.onChange} orientation="vertical">
<frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundTransparency={1} Size={UDim2.fromOffset(240, 0)}>
<uilistlayout Padding={new UDim(0, 4)} SortOrder={Enum.SortOrder.LayoutOrder} />
{OPTIONS.map((option, index) => (
<RadioGroup.Item key={option} value={option} asChild>
{/* 40px rows: easy touch target, clear gamepad ring stop */}
<textbutton
AutoButtonColor={false}
LayoutOrder={index}
Size={new UDim2(1, 0, 0, 40)}
Text={option}
TextSize={15}
/>
</RadioGroup.Item>
))}
</frame>
</RadioGroup.Root>
);
}

Pressing ↓ on “medium” focuses and selects “high”. Flicking the gamepad stick does the same through the engine, and tapping any row selects it directly. Disable an option and every input mode skips it.