Keyboard and gamepad navigation
Move between menu controls, set submit/back bindings, and switch input between menus and gameplay.
Add NavigationProvider and NavigationScope to move between HTML buttons,
links, and form controls with a keyboard or gamepad. Start with a running Loom scene from Install and run Loom.
For a complete example that opens a menu and disables gameplay input, follow Build a pause menu.
Set up menu navigation
Gamepad forwarding requires Active Input Handling to be Input System Package (New) or Both. Loom logs a warning in projects that use only the legacy Input Manager.
In the starter’s UI/src/index.tsx, add NavigationProvider inside the
existing BridgeProvider:
import { BridgeProvider, NavigationProvider } from '@loomgui/bridge'
render(() => (
<BridgeProvider value={bridge}>
<NavigationProvider>
<App />
</NavigationProvider>
</BridgeProvider>
), root)
Then declare each navigable screen as a named scope. This example assumes your
bridge exposes Resume() and OpenSettings() actions:
import { NavigationScope, useBridge } from '@loomgui/bridge'
export function PauseMenu() {
const bridge = useBridge()
return (
<NavigationScope
name="pause"
defaultFocus="#resume"
backTarget="#resume"
class="pause-menu"
>
<button id="resume" onClick={() => bridge.resume()}>Resume</button>
<button id="settings" onClick={() => bridge.openSettings()}>Settings</button>
</NavigationScope>
)
}
Buttons, links, inputs, textareas, selects, and elements with a non-negative tabindex are discovered automatically. Hidden, inert, disabled, and aria-disabled="true" controls are skipped.
The defaults are:
- D-pad or left stick: move focus.
- South button or Enter: activate the focused control.
- East button or Escape: click the active scope’s
backTarget. - Hold a direction: move once, wait for the initial delay, then repeat.
The controller uses real DOM focus. Opening a nested scope focuses its remembered
control, defaultFocus, or the first eligible control, in that order. Closing
it restores focus in the previous scope. A named scope also remembers the last
focused element by ID when that screen is unmounted and mounted again. If a
reactive update disables, hides, or removes the focused control, controller
focus moves through that same fallback order. A screen without NavigationScope receives no automatic controller navigation; this is
appropriate for gameplay HUDs.
Loom chooses the next control by its position on screen. To override a direction, name the control to focus:
<button id="less" data-loom-nav-right="#reset">−</button>
<button
id="reset"
data-loom-nav-left="#less"
data-loom-nav-right="#more"
>
Reset
</button>
<button id="more" data-loom-nav-left="#reset">+</button>
The supported attributes are data-loom-nav-up, data-loom-nav-right, data-loom-nav-down, and data-loom-nav-left. Use an ID selector for an
explicit neighbor, auto for geometry, or none to block that exit. Targets
must be eligible controls inside the same scope.
<input>, <textarea>, and <select> keep their normal keyboard behavior. Arrow keys and Enter are left to an editable control while it owns
keyboard focus; controller directions can still move out of it. Clicking a control gives it focus; the controller continues from that control.
You can change submit and back bindings with logical button names:
<NavigationProvider bindings={{ submit: 'south', back: 'east' }}>
<App />
</NavigationProvider>
Supported names are south, east, west, north, leftBumper, rightBumper, leftTrigger, rightTrigger, select, start, leftStick,
and rightStick. Directional timing can also be changed at the provider:
<NavigationProvider
timing={{
deadZone: 0.55,
releaseZone: 0.35,
initialRepeatDelay: 350,
repeatInterval: 100,
}}
>
<App />
</NavigationProvider>
These are the defaults. Delay and interval values use milliseconds, and releaseZone must be lower than deadZone.
Loom dispatches a bubbling, cancelable loomnavigation event before its default
move, submit, or back behavior. Call preventDefault() when a screen needs to
handle a command itself.
NavigationProvider does not switch Unity action maps. When the game opens or
closes a menu, switch its own gameplay/UI action map in the same C# operation
that changes the visible bridge screen. This prevents gameplay actions from
responding to controller input that currently belongs to the menu.
Keyboard ownership
Unity gameplay code and the UI receive the same keys. Keep HUDs in Gameplay mode. When opening a menu, switch Loom to Navigation and the
game’s action map to UI; switch both back when closing it:
private void OpenInventory()
{
playerInput.SwitchCurrentActionMap("UI");
LoomRuntime.KeyboardMode = LoomKeyboardMode.Navigation;
UIBridge.Instance.CurrentScreen = GameScreen.Inventory;
}
private void CloseInventory()
{
LoomRuntime.KeyboardMode = LoomKeyboardMode.Gameplay;
playerInput.SwitchCurrentActionMap("Gameplay");
UIBridge.Instance.CurrentScreen = GameScreen.Hud;
}
In Navigation mode, Tab and Shift+Tab move through ordinary focusable HTML
controls. A Tab already held when the menu opens is ignored through its release,
so the opening press does not also advance focus. Use standard tabindex, disabled, and conditional rendering to control the focus order and active scope.
Call these transitions through bridge actions so C# changes the screen, action map, and keyboard mode together.
Raw Gamepad API
Use the browser Gamepad API to read individual controller buttons and axes:
window.addEventListener('gamepadconnected', (event) => {
console.info(`Controller ${event.gamepad.index} connected`)
})
const primary = [...navigator.getGamepads()].find((gamepad) => gamepad !== null)
const submit = primary?.buttons[0].pressed ?? false
const horizontal = primary?.axes[0] ?? 0
Loom reports the standard four axes and 17-button layout. The system button at index 16 stays neutral because Unity does not expose one portable control for it. Loom does not expose controller vibration in this release.
Unity actions and the page can read the same controller. NavigationProvider also reads this API; it does not reserve the controller for the UI.
In an external browser, the browser supplies its own controller devices through the Gamepad API. Verify Unity input settings in the embedded game view.
Troubleshooting navigation
If input does not reach the UI, check:
- A
LoomInputCaptureexists on the activeLoomView. - There is only one active Loom view.
- The game is in Play mode and the UI has finished loading.
- For gamepads, Active Input Handling is Input System Package (New) or Both.
- The app has one
NavigationProvider, and the visible menu has an activeNavigationScope. - The scope contains at least one visible, enabled focus target with a stable ID for remembered or explicit focus.