A Roblox experience is played with whatever is in the player’s hands: a mouse, a touchscreen, a keyboard, a gamepad — often several in the same 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; gamepad navigation rides Roblox’s selection engine, with the focus manager keeping it inside the right surface. This guide covers the input side of that story — how movement and activation actually reach your components. For scopes, trapping, and restore, see Focus management.
One item, three input classes
Every interactive lattice item — a tab trigger, a radio item, a menu item — wires the same three Roblox event surfaces:
Activated— fires on mouse click, touch tap, and gamepad A. This is the “press” path for pointers and gamepads.InputBegan— receives keyboard input while the item holds selection. This is where arrow-key movement and Enter/Space activation are handled.SelectionGained/SelectionLost— fire whenGuiService.SelectedObjectlands on or leaves the item, whether the engine moved it (gamepad) or lattice did (keyboard, imperative focus). This is where selection visuals and selection-follows-focus behavior live.
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” and “previous 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 entries by theirorderfield, so movement order is declaration order, not render or array order.- An entry is available only if its
GuiObjectexists, is not disabled, isVisible(andgetVisible()does not veto), and hasSelectableset. Unavailable entries are skipped entirely. getRelativeOrderedSelectionEntry(entries, currentId, direction)steps-1or+1through the available entries and clamps at the ends — it does not wrap. With no current entry,+1resolves the first available item and-1the last.focusOrderedSelectionEntry(entry)hands the result to the focus manager, which selects the underlyingGuiObject.
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 neighbor:
const moveSelection = React.useCallback((direction: -1 | 1) => { const currentItem = getCurrentOrderedSelectionEntry(itemEntriesRef.current); const nextItem = getRelativeOrderedSelectionEntry(itemEntriesRef.current, currentItem?.id, direction); focusOrderedSelectionEntry(nextItem);}, []);Because availability is checked at move time — live refs, live getDisabled, live Visible — a disabled or hidden item is skipped without any re-registration. You never have to rebuild the registry when an item’s state changes.
Gamepad selection
On gamepad, lattice does not intercept the thumbstick or d-pad. Movement between selectable objects is Roblox’s own directional navigation: the engine walks GuiService.SelectedObject between GuiObjects whose Selectable property is true, using on-screen geometry, and draws its default selection ring around the result. lattice-ui does not set SelectionGroup or replace SelectionImageObject — what it does instead:
- Primitives keep
Selectabletruthful. Every item renders withSelectable={!disabled}(andActive={!disabled}), so the engine can only land on things your logic considers interactive. - The focus bridge reads movement back. While any
FocusScopeis active, the manager listens toGuiService.SelectedObjectchanges. When the engine moves selection, the model updates to match — and if a trapped scope is active and selection escaped it, the manager pulls selection back to a focusable node inside. See Focus management for the trap rules. SelectionGaineddrives your visuals and state. Tabs and RadioGroup select their value the moment selection lands on an item (“selection follows focus”), and Menu highlights the selected item 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 on gamepad is the engine’s job too: pressing A on the selected object fires Activated, the same handler a click or tap runs. You do not write 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 that is outside the topmost enabled layer’s content (hit-tested with GetGuiObjectsAtPosition, with inset-compensated sample points) fires onPointerDownOutside and onInteractOutside, then dismisses the layer unless a handler calls preventDefault(). 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; tapping any row selects it directly. Disable an option and every input mode skips it.