A universal intent system for Game-menu style button control for Vue 3: one focused trigger at a time, navigable with keyboard, mouse, and gamepad through a single API.
- Spatial navigation is computed from the DOM positions of the registered triggers, so "where does up go?" is answered automatically and there is no focus graph to maintain.
- Keyboard, mouse, and gamepad ship as separate adapters that you configure explicitly. The core only knows the
InputAdaptercontract, so input sources can be omitted, replaced, or added. - Shortcuts are unified across inputs:
Escape, gamepadB, and the mouse back button can all fire the same trigger as a visible button. - Focus layers let a modal confine navigation and shortcuts to its own triggers and restore the previous focus when it closes.
- Triggers use real
element.focus(), and every trigger of the active layer stays in the native Tab order, so pressing Tab moves between triggers and any landing is adopted as focus. Adata-uni-focusedattribute is set for styling.
import { createApp } from "vue";
import {
createUniIntent,
keyboardAdapter,
mouseAdapter,
gamepadAdapter,
} from "vue-uni-intent";
createApp(App)
.use(
createUniIntent({
// pick the input sources you want
adapters: [
keyboardAdapter({
// all keys are configurable; these are the defaults
keys: {
up: ["ArrowUp"],
down: ["ArrowDown"],
left: ["ArrowLeft"],
right: ["ArrowRight"],
activate: ["Enter", " "],
},
}),
mouseAdapter(),
gamepadAdapter({
deadzone: 0.5,
initialRepeatDelay: 400,
repeatInterval: 150,
rightStickScroll: true, // right stick scrolls the focused item's scroll container (default off; standard-mapping pads only)
scrollSpeed: 1200, // px/sec at full deflection; invertScrollY to flip Y
}),
],
enabled: true, // global on/off for input control; pass a ref/getter to toggle at runtime
wrap: false, // wrap-around navigation at the edges
initialFocus: "first", // or "none"
scroll: true, // scroll the focused trigger into view; or ScrollIntoViewOptions / false
scrollMargin: { top: 64 }, // keep-clear space so a sticky header never covers focus
debug: import.meta.env.DEV, // enable the spatial-nav debug overlay (Ctrl+Alt+D)
}),
)
.mount("#app");Navigation is edge-based, like browsers (Blink / the CSS spatial-navigation
spec) and game engines — never center-to-center. Pressing a direction keeps only
the triggers whose leading edge clears the origin's leading edge in that
direction, then picks the one minimizing forwardGap + crossGap × 2, where
crossGap is 0 while the rects overlap on the cross axis. Because it reads
rectangle edges, it matches what you see: a full-bleed element has nothing to
its left or right, a focus-lift animation or sub-pixel stagger can't divert a
straight press to a sideways neighbor, and a pinned header never wins just
because its center is close. wrap cycles to the opposite edge of the same
row/column at the boundaries.
Focus moves scroll the new target into view (real focus({ preventScroll })
followed by scrollIntoView). Control it with scroll:
true(default) —{ block: "nearest", inline: "nearest" }false— never scroll; handle it yourself inonFocusScrollIntoViewOptions— e.g.{ block: "center" }to keep focus centered
Behind a sticky header, use scrollMargin so focus is never parked
underneath it — set the top margin to (at least) the header's height:
createUniIntent({ /* … */ scrollMargin: { top: 64 } }); // or a number for all sidesscrollMargin is applied as the trigger's scroll-margin (which
scrollIntoView honors), so it needs no CSS. Equivalently you can set
scroll-margin on the triggers or scroll-padding on the scroll container
yourself and leave scrollMargin unset.
A single trigger can override the global margin — e.g. a section under a taller
header — by passing scrollMargin to useTrigger (same number / per-side
shape):
useTrigger({ id: "row", onTrigger: open, scrollMargin: { top: 120 } });enabled is a global on/off switch for input-driven focus control. Pass a ref
or getter and toggle it at runtime to suspend the whole system — while it is off,
every adapter (keyboard, mouse, gamepad, custom) is ignored: navigation,
activation, hover-focus, and shortcuts all no-op. Triggers stay registered and
your own useTrigger().focus() / .trigger() keep working, so nothing is torn
down — flip it back on and input resumes where it left off.
const enabled = ref(true);
createUniIntent({ /* … */ enabled });
// later, e.g. while a non-uni-intent overlay owns the input:
enabled.value = false;Set debug: true (or gate it behind import.meta.env.DEV) to make a
spatial-navigation overlay available, toggled at runtime with Ctrl+Alt+D. It
boxes every visible trigger with its per-direction scores in place (winner
highlighted), draws the winning connector lines, and renders a full score matrix
so you can see exactly why a direction goes where it goes. Pass
debug: { hotkey: key("d", { ctrl: true, shift: true }) } to rebind the toggle.
createUniIntent({ /* … */ debug: import.meta.env.DEV });<script setup lang="ts">
import { useTrigger, key, button, mouseButton } from "vue-uni-intent";
const { ref: el, focused, focus, trigger } = useTrigger({
id: "back",
onTrigger: () => router.back(),
onFocus: (cause) => preview(cause), // optional: runs whenever this trigger gains focus
// optional: the same handler fires on ESC, gamepad B, or the mouse back button
shortcuts: [key("Escape"), button("B"), mouseButton("Back")],
disabled: () => busy.value, // skipped by navigation and shortcuts
autofocus: true, // take the layer's initial focus
});
</script>
<template>
<button ref="el" :class="{ active: focused }">Back</button>
</template>The focused trigger can be styled via the attribute hook:
[data-uni-focused] {
outline: 3px solid dodgerblue;
}If you leave the ref unbound, the trigger acts as a pure shortcut handler: it
participates in the trigger system but not in spatial navigation.
onTrigger receives a TriggerCause: source (the firing adapter's name, or
"manual"), via ("activate", "shortcut", or "manual"), and whatever the
adapter attached — a native event for keyboard/mouse, the button index for
gamepad. The core stays input-agnostic, so the shape is open; type guards narrow
it:
import { isKeyboardCause, isGamepadCause } from "vue-uni-intent";
onTrigger: (cause) => {
if (isKeyboardCause(cause)) cause.event.preventDefault(); // KeyboardEvent
else if (isGamepadCause(cause)) console.log(cause.button); // standard-mapping index
save();
};isMouseCause and isManualCause are exported too. Custom adapters attach their
own detail; narrow it with your own guard.
onFocus mirrors onTrigger for focus changes: it runs every time a trigger
gains focus and receives a FocusCause. Like TriggerCause, adapter-driven
focus carries the adapter's source and any native event; focus the core
resolves itself carries source: "core", and a programmatic focus() carries
source: "manual". The via field says how focus was gained:
"navigate"— spatial navigation (directionis set)"focus"— direct focus of a trigger (mouse hover/click)"programmatic"— auseTrigger().focus()call"tab"— native focus landing on a trigger, adopted viafocusin(Tab/Shift+Tab, or a click)"restore"— a layer became active again and its remembered focus returned"initial"— a layer/app resolved its initial focus"cleanup"— the focused trigger was removed and focus fell back to a survivor
import { isKeyboardFocusCause, isManualFocusCause } from "vue-uni-intent";
onFocus: (cause) => {
if (cause.via === "navigate") announce(cause.direction); // "up" | "down" | …
if (isKeyboardFocusCause(cause)) cause.event.preventDefault(); // KeyboardEvent
if (isManualFocusCause(cause)) return; // ignore our own focus() calls
};isMouseFocusCause, isGamepadFocusCause, and isCoreFocusCause are exported
too.
<script setup lang="ts">
import { useTriggerLayer } from "vue-uni-intent";
// While this component is mounted (and topmost), navigation and shortcuts are
// confined to its descendants' triggers. Unmounting restores previous focus.
const { isActive } = useTriggerLayer({ id: "settings-modal" });
</script>useAvailableInputs() reactively reports which input sources can actually be
used right now, so you can adapt the UI — swap button-prompt glyphs, hide
keyboard hints on a touch phone, show a "controller connected" badge:
<script setup lang="ts">
import { useAvailableInputs } from "vue-uni-intent";
const { available, keyboard, mouse, touch, gamepad } = useAvailableInputs();
// available -> reactive ReadonlySet of source names, e.g. Set { "keyboard", "mouse" }
// keyboard / mouse / touch / gamepad -> reactive booleans
// available.has("midi") — or use has("midi") — for custom adapters
</script>Availability reflects real device capability, reported by the installed adapters — nothing is available unless its adapter is installed:
keyboard— inferred from the primary pointer (a fine pointer implies a desktop with a keyboard; a coarse one implies a touch device without), then confirmed the moment any real keydown arrives (so a Bluetooth keyboard on a tablet flips it on).mouse— a fine pointer (mouse/trackpad) exists ((any-pointer: fine)).touch— a coarse pointer (finger) exists ((any-pointer: coarse)).mouseandtouchare independent: a hybrid laptop reports both.gamepad— at least one controller is connected; flips on and off as controllers connect and disconnect.
import { key, button, mouseButton, GamepadButton, MouseButton, GamepadAxis } from "vue-uni-intent";
key("Escape"); // { key: "Escape" }
key("s", { ctrl: true }); // { key: "s", ctrl: true }
button("B"); // { button: 1 } (standard-mapping index via GamepadButton)
mouseButton("Back"); // { mouseButton: 3 }
mouseButton("Right", { ctrl: true }); // { mouseButton: 2, ctrl: true }Note that gamepad names follow the standard mapping by position. On Nintendo controllers the physical A/B and X/Y labels are swapped relative to it.
An adapter is any object implementing InputAdapter. It receives an
AdapterContext in setup, which is its only connection to the core. When it
fires a trigger it passes a cause — a source (usually
its name), a via, and any native event or detail:
import type { InputAdapter } from "vue-uni-intent";
export function midiAdapter(): InputAdapter {
let stop = () => {};
return {
name: "midi",
setup(ctx) {
// wire your input source to:
// ctx.move("up" | "down" | "left" | "right")
// ctx.activate({ source: "midi", via: "activate", note })
// ctx.focus(id)
// ctx.dispatchShortcut(input, { source: "midi", via: "shortcut", note })
// ctx.setAvailable("midi", true) — surfaces via useAvailableInputs()
},
teardown() {
stop();
},
};
}pnpm install
pnpm storybook # interactive examples (playground/stories/)
pnpm dev # minimal dev app + e2e smoke page (playground/)
pnpm test:unit # vitest
pnpm build # type-check + library build to dist/
pnpm lintThe Storybook stories serve as usage examples: grid navigation (wrap modes,
disabled triggers, custom WASD bindings), focus strategies, global shortcuts,
modal and nested layers, programmatic control, and a full game-menu example.
Stories override plugin options per story via parameters.uniIntent (see
.storybook/preview.ts).
This library was built with the help of AI (Claude). AI was used to assist with implementation, tests, and documentation; all output was reviewed and is maintained by a human.