Focus & navigation#

Game UIs are driven by keyboard and gamepad as much as the mouse, so a correct focus ring matters. onejs-ui ships a focus-visible model that's wired up automatically, so you generally get the right behavior for free.

The focus-visible model#

The themeable ring (colored by the ring token) shows only while the input modality is keyboard or gamepad, and is suppressed on pointer use, the same idea as the web's :focus-visible. This is what keeps mouse and keyboard from fighting over the ring:

  • Press an arrow key or Tab → modality becomes keyboard; the focused control shows its ring.
  • Click or tap → modality becomes pointer; the ring hides.
  • Navigate again → the ring reappears on the focused control.

<ThemeProvider> initializes the manager, so every built-in control gets correct nav rings with zero per-app wiring. The ring follows focus across Button, Checkbox, Switch, Input, Slider, and RadioGroup, and survives mixing mouse with keyboard.

What you get automatically#

When your app is wrapped in <ThemeProvider>:

  • A focus ring that tracks keyboard/gamepad navigation and suppresses itself on pointer use.
  • Initial keyboard focus on play start, so arrow keys work without clicking the panel first.
  • Space and Enter both activate the focused control.

No imports or hooks required for the common case.

FocusScope#

For overlays and custom regions, <FocusScope> manages focus lifecycle: autofocus on mount, focus restoration on unmount, and an optional focus trap. Dialog and Drawer use it internally; reach for it directly when you build your own modal surface.

import { FocusScope } from "onejs-ui"

<FocusScope>
    <View>{/* focus stays inside while open */}</View>
</FocusScope>
PropDefaultDescription
autoFocustrueFocus the first focusable element on mount.
restoreFocustrueReturn focus to the previously focused element on unmount.
traptrueKeep navigation inside the scope while mounted.

Pass trap={false} for autofocus and restore without a trap.

useFocusReturn() is the underlying hook if you want focus restoration without the full scope.

Reading the modality#

useFocusVisible() exposes the current modality, useful for hints like "Press Tab to navigate" or for swapping affordances between mouse and gamepad layouts:

import { useFocusVisible } from "onejs-ui"

function Hint() {
    const { modality } = useFocusVisible() // "pointer" | "keyboard"
    return modality === "keyboard" ? <Text tone="muted">Use arrows to navigate</Text> : null
}

Ringing your own focusable elements#

If you build a custom focusable View, the simplest path is the built-in ring primitive: add RING_CLASS to the element and the manager paints the themed ring on keyboard focus, with no USS of your own required.

import { RING_CLASS } from "onejs-ui"

// a focusable View that rings on keyboard focus, like the built-in controls
<View focusable className={RING_CLASS}>{/* ... */}</View>

For full control over the look, write the rule yourself in a .module.uss, wrapping the manager's class in :global(...) so the CSS-module scoper leaves it alone: .myControl:global(.focus-ring) { border-color: var(--ojs-ring); }. The manager toggles FOCUS_RING_CLASS ("focus-ring") on the focused element while in keyboard modality.

How it works#

Under the hood, the manager tracks input modality from __root events, reads the live focused element on navigation plus a tick-based focuschange signal from the OneJS runtime, and toggles the ring class imperatively (never via React state, which would re-render and blur the focused element). This is why a recent OneJS runtime is recommended, and why onejs-react 0.2.2 or newer is needed: older versions do not bubble events up to __root. See Overview → Requirements.