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:
import { BridgeProvider, NavigationProvider } from '@loomgui/bridge'
render(() => (
<BridgeProvider value={bridge}>
<NavigationProvider>
<App />
</NavigationProvider>
</BridgeProvider>
), root)
Then declare each navigable screen as a named scope:
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:
<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:
<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.
Raw Gamepad API
Use the standard browser Gamepad API when gameplay or a custom widget needs raw controller state:
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:
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
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.
- 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.
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:
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.