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 whenGuiService.SelectedObjectlands on or leaves the item, whether the engine moved it or lattice did. Selection visuals and selection-follows-focus live here.
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:
getOrderedSelectionEntriessorts by theorderfield, so movement order is declaration order, not render or array order.- An entry is available only if its
GuiObjectexists, is not disabled, isVisible, and hasSelectableset. 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,+1resolves the first available item and-1the 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:
const moveSelection = React.useCallback((direction: -1 | 1) => { const currentItem = getCurrentOrderedSelectionEntry(itemEntriesRef.current); const nextItem = getRelativeOrderedSelectionEntry(itemEntriesRef.current, currentItem?.id, direction); focusOrderedSelectionEntry(nextItem);}, []);Availability is checked at move time against live refs and state, so a disabled or hidden item is skipped without re-registration. You never rebuild the registry when an item’s state changes.
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
Selectabletruthful. Every item renders withSelectable={!disabled}, so the engine only lands on things your logic considers interactive. - The focus bridge reads movement back. While any
FocusScopeis active, the manager listens toGuiService.SelectedObjectchanges and updates the model to match. It pulls selection back inside if a trapped scope is active and it escaped. See Focus management. SelectionGaineddrives 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 forMouseEnter.
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:
| Component | Movement keys | Activation |
|---|---|---|
Tabs.Trigger | ←/→ when orientation="horizontal", ↑/↓ when vertical | Enter, 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.
<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
textbuttongetsSelectable={false}andActive={false}, so the engine’s gamepad navigation skips it and it stops firingActivated. - 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.
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.