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 — 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.
  • Move or click the mouse → 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 autoFocus restoreFocus trap>
    <View>{/* focus stays inside while open */}</View>
</FocusScope>
PropDescription
autoFocusFocus the first focusable element on mount.
restoreFocusReturn focus to the previously focused element on unmount.
trapKeep navigation inside the scope while mounted.
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 — 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 className={RING_CLASS}>{/* ... */}</View>

For full control over the look, use FOCUS_RING_CLASS instead: add a .focus-ring rule in your own .module.uss (the class name is preserved by the CSS-module scoper) that styles the focused state however you like (e.g. border-color: var(--ojs-ring)). The manager toggles FOCUS_RING_CLASS 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 — see Overview → Requirements.