@standarx/nav
v0.3.2
Published
Spatial navigation for the web: d-pad, gamepad sticks, TV remotes and arrow keys drive real DOM focus, declared in data attributes.
Maintainers
Readme
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.3.2 is on npm since 2026-10-13. 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; a candidate the browser refuses to focus hands the move to the next one, and the areas of an image map are candidates, each placed by its shape over its image (ADR-0031);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.38 kB min+gzip for the core, 1.42 kB to add the React adapter next to it, 3.54 kB for the spatial engine, by
bun run check:sizeon 2026-09-26 (ADR-0017). You pay for what you import and nothing else.
Usage
bun add @standarx/navimport { 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). It also takes the arrows of a native radio group or range, unless a scope answers "native" for them (docs/en/navigation.md, ADR-0026).
| 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. A match that cannot take the focus, or no match, is ignored and the geometry runs as if the attribute were absent. |
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/vue | NavProvider and the composables, for Vue 3.3 and later — docs/en/vue.md. |
| @standarx/nav/svelte | provideNav and the functions around it, for Svelte 5.0 and later — docs/en/svelte.md. |
| @standarx/nav/angular | provideNav and the inject* functions, for Angular 20.0 and later — docs/en/angular.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, Vue, Svelte and Angular are the adapters shipped today, which completes the order of ADR-0011, all four held to the same parity suite (ADR-0027 for Vue, ADR-0028 for Svelte, ADR-0029 for Angular).
Documentation
- docs/en/: the attribute tables, how a move is decided and what makes an element navigable, React, Vue, Svelte, Angular, 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 eleven checks on every pull request: lint, typecheck, build with the packaging and size gates, unit tests, a React 18.3 floor, a Vue 3.3.0 floor, a Svelte 5.0.0 floor, an Angular 20.0.0 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.
