Guides Keyboard and gamepad navigation
Guides

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:

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

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

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

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

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

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

ts
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 LoomInputCapture exists on the active LoomView.
  • 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 active NavigationScope.
  • The scope contains at least one visible, enabled focus target with a stable ID for remembered or explicit focus.
Loom documentation

Search docs

Esc

Type a word or phrase to search the documentation.

Type a word or phrase to search the documentation.