Unity integration View, input, and DPI
Unity integration

View, input, and DPI

How Loom displays the UI overlay, forwards input, handles transparency, and reports viewport size.

LoomView displays your UI as a fullscreen overlay over the Unity scene. LoomInputCapture forwards pointer, keyboard, and gamepad input to the UI.

You do not need to add these components by hand. Run:

Tools > Loom > Setup UI in Current Scene

and Loom creates the right hierarchy.

Input

LoomInputCapture forwards pointer and keyboard input while the Loom view is active. These inputs work with Unity’s legacy Input Manager, the Input System, or both.

Standard HTML <input> and <textarea> controls receive normal text entry on Windows and macOS. Loom uses the operating system’s resolved characters, so Shift, the active keyboard layout, accented characters, and committed Unicode text are preserved. Enter, Backspace, Delete, arrow keys, and selection shortcuts continue through the keyboard-event path.

Gamepads

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. The repository sample uses Both.

For menu navigation, install one provider around the app:

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:

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. No button wrapper, registration hook, polling loop, or focus registry is required.

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.

Automatic geometry handles ordinary rows, columns, and grids. Add an explicit neighbor only where the layout needs a deterministic route:

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.

Real <input>, <textarea>, and <select> controls 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. Mouse clicks switch the input modality and focus the clicked control without leaving a separate controller selection behind.

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.

Raw Gamepad API

Use the standard browser Gamepad API when gameplay or a custom widget needs raw controller state:

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.

Gamepad input is forwarded, not consumed. Unity actions and the page can read the same controller. NavigationProvider reads this API for menus, while direct navigator.getGamepads() access remains available for custom behavior.

In an external browser, the browser supplies its own Gamepad API. Loom does not send controller state through the typed bridge or the Editor WebSocket debug tap.

Keyboard input is also forwarded, not consumed. Unity gameplay code and the UI can observe the same key, so keep normal HUDs in the default gameplay mode. When a menu owns keyboard navigation, change Loom’s mode and the game’s action map together:

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.

The UI should request these transitions through a bridge action rather than changing keyboard ownership directly in JavaScript. That keeps the Unity action map, current screen, and Loom mode in one authoritative operation.

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.
  • After replacing the native plug-in, Unity was fully quit and reopened. Unity keeps loaded native libraries pinned until the Editor exits.

Transparency

Loom is designed for transparent overlays. Keep the app background transparent for HUDs, and apply explicit backgrounds only to menus or panels that need one.

css
html,
body,
#root {
  background: transparent;
}

For full-screen menus, add a backdrop to the screen component instead of the entire app.

DPI and viewport size

Loom reports viewport and DPI through built-in state:

TSX
const bridge = useBridge();

bridge.loom.viewportWidth;
bridge.loom.viewportHeight;
bridge.loom.devicePixelRatio;

Use these values when the UI needs to react to actual game viewport size. For pure layout changes, normal CSS media queries are usually enough.

One UI overlay

Most games should use one Loom overlay for the whole game. Keep it alive across scenes and switch screens through bridge state rather than destroying and recreating the UI for every scene.

Loom documentation

Search docs

Esc

Type a word or phrase to search the documentation.

Type a word or phrase to search the documentation.