standarnav
Spatial navigation for the web: d-pad, gamepad sticks, TV remotes and arrow keys drive real DOM focus, declared in data attributes.
Status: 0.1.0 is on npm since 2026-09-22. The public API is not frozen and the data-snav-* attribute names are not final (ADR-0001); while the major is 0, a minor may break. See ROADMAP.md.
What it does
- Moves the real DOM focus: the engine calls
focus()and readsdocument.activeElement. No virtual cursor, no shadow focus state (ADR-0005). - Plain HTML becomes navigable through attributes; the application writes no navigation code for it.
- Arrow keys, d-pad buttons, the left stick and TV remote keys produce the same intent, so no move logic branches per device. The right stick scrolls.
- Containers nest and remember the element last focused in them. A container can wrap in one axis, block a direction, trap every exit, or redirect a direction to a CSS selector; when nothing is found, the engine scrolls one step and rescans once, which is how a virtualised list keeps producing rows.
- Two modes:
composite(arrow keys stay inside composites, a gamepad crosses the page) andapp(arrow keys navigate the whole page).onWillMovecan veto a move;onBoundsHitfires when a direction has nowhere left to go. - Zero runtime dependencies, ESM only, and every subpath sits under a size cap CI enforces: 3.13 kB min+gzip for the core and 12.40 kB for the whole package, by
bun run check:sizeon 2026-09-22 (ADR-0017).
Usage
bun add @standarx/nav
import { createInputSystem } from "@standarx/nav";
import { gamepadPlugin } from "@standarx/nav/gamepad";
import { spatialPlugin } from "@standarx/nav/spatial";
const input = createInputSystem({ plugins: [gamepadPlugin(), spatialPlugin({ mode: "app" })] });
// later: input.destroy();
mode: "app" is what makes that snippet move on a keyboard. The default, composite, keeps arrow keys inside the composite that holds the focus, APG-strict, and only Tab crosses between composites; a gamepad has no Tab and crosses the page in either mode. Under app a hover also moves the focus, so a mouse and a pad do not fight over two cursors (ADR-0007).
| Attribute | Placed on | Value | Effect |
|---|---|---|---|
data-snav="container" |
any element | fixed | Declares a navigation container; moves are scored inside it first. |
data-snav-enter |
a container | last | first | nearest |
Which element takes focus when a move enters. Default last. |
data-snav-wrap |
a container | x | y | both, or bare |
Wraps to the opposite edge instead of leaving the container. |
data-snav-block |
a container | directions separated by spaces, or bare | Blocks those exits; bare blocks every one. |
data-snav-trap |
a container | bare | A move never leaves this container. |
data-snav-scroll |
a container | center |
Scrolls a newly focused element to the centre instead of nearest. |
data-snav-ignore |
any element | bare | Excludes the element from the candidate list. |
data-snav-up / -down / -left / -right |
a focusable | a CSS selector | Sends that direction to the first match in the document, before any geometry runs. |
The attributes the engine writes back, for styling, are in docs/en/attributes.md; how a move is decided and what makes an element navigable are in docs/en/navigation.md.
Subpaths
Import only what you use: the root entry pulls in no engine.
| Subpath | What it adds |
|---|---|
@standarx/nav |
createInputSystem, intent scopes, engage mode for controls that hold a value, the modality tracker, the focusability helpers, the types. |
@standarx/nav/spatial |
The engine that turns a direction into a focus move: move, focus, focusFirst, onWillMove, onBoundsHit. |
@standarx/nav/gamepad |
navigator.getGamepads polling: standard mapping, dead zones, repeat, rumble. |
@standarx/nav/focus-ring |
One overlay that follows focusin, painted inline, six custom properties — docs/en/focus-ring.md. |
@standarx/nav/keyboard |
An on-screen keyboard whose keys are real buttons the engine navigates; a layout is data in /keyboard/qwerty, /keyboard/azerty or /keyboard/alphabetic (ADR-0022). |
@standarx/nav/debug |
explainMove, the scored candidates of a move; scanNativeSelects, the closed <select>s a television cannot open. |
@standarx/nav/react |
NavProvider and the hooks — docs/en/react.md. |
@standarx/nav/auto |
autoMount for a page with no framework: waits for the document, and lets data-snav-mode on the root pick the navigation mode — docs/en/auto.md. |
Frameworks
The core is the vanilla API and needs no framework, and @standarx/nav/auto is a start-up helper next to it rather than an adapter (ADR-0023). React is the adapter shipped today; Vue, Svelte and Angular follow in that order (ADR-0011), each held to the same parity suite as React before it ships.
Documentation
- docs/en/: the attribute tables, how a move is decided and what makes an element navigable, React, the auto-mount helper, the focus ring. Mirrored file by file in docs/fr/.
- Playground: the fixture page, driven by keyboard and gamepad.
bun run devserves it fromsrc/, andplayground/widgets.tsholds the recipes for a slider, a stepper, a wheel picker, a listbox, a splitter and a native<select>. - Browser support: Chromium 85, Safari 15 and Firefox 79 are the declared floor, television runtimes of 2020-2021 are best effort, and no television has been tested (ADR-0013, docs/research/tv-runtime-compatibility.md).
- docs/specification.md: the problem, the non-goals, the requirements and the gate each public claim has to pass. docs/adr/README.md: every decision with its evidence — package layout ADR-0011, versioning and release ADR-0012.
- docs/research/competitors.md: eighteen projects compared on real focus, attributes, gamepad and framework independence, with sources.
- Where it comes from: the engine is extracted from a predecessor implementation, identified in ADR-0002 and nowhere else.
Contributing
CONTRIBUTING.md, in one line: conventional commits, bun never npm, a test with every behaviour change, blocking size budgets, English only, and no claim in a document without the command, date or URL that proves it. CI runs eight checks on every pull request: lint, typecheck, build with the packaging and size gates, unit tests, a React 18.3 floor, and the browser suite on chromium, firefox and webkit. Conduct is CODE_OF_CONDUCT.md; a vulnerability goes through SECURITY.md, never a public issue.
License
MIT, copyright Wesley Cormier. See LICENSE.