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.
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>| Prop | Description |
|---|---|
autoFocus | Focus the first focusable element on mount. |
restoreFocus | Return focus to the previously focused element on unmount. |
trap | Keep 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.