@ozjsey/v-keyboard-navigation

Vue 3 directive that turns a group of controls into one tab stop — roving tabindex, arrow keys, typeahead, Home/End, real PageUp/PageDown — with the controlled scroll (focus preventScroll + block:'nearest') the browser's own focus scroll gets wrong
904
Install
npm install @ozjsey/v-keyboard-navigation
Latest Version:0.3.2
Vue:3
License:MIT
Last Updated:Sep 20, 2026
Links: GitHub  ·  npm
Maintainer: ozjsey

@ozjsey/v-keyboard-navigation

One tab stop for a group of controls. A Vue 3 directive that adds roving tabindex, arrow keys, Home/End, typeahead and PageUp/PageDown to markup you already have.

npm license MIT gzipped 5.38 KiB dependencies 0

The problem

Roving-tabindex libraries — Radix, Reka, Primer, keyux, makeup — contain zero occurrences of scrollIntoView, and that is deliberate: the APG says one benefit of roving tabindex over aria-activedescendant is that "the user agent will scroll the newly focused element into view."

But the user agent scrolls badly. It centres the focused item, so a five-row viewport lurches three rows at a time and the list jumps under the user's eyes.

The solution

The same roving tabindex every other implementation gives you, plus the scroll they hand back to the browser — hold ArrowDown against the live trace. Measured in headless Chrome — a 200px viewport, 40px rows, one ArrowDown per step, scrollTop read back after each key:

scrollTop after each key
focus() — what every primitive ships 0,0,0,0,120,120,120,240,240,240,360,360
focus() then scrollIntoView({block:'nearest'}) 0,0,0,0,120,120,120,240,240,240,360,360
focus({preventScroll:true}) then 'nearest' 0,0,0,0,40,80,120,160,200,240,280,320

Look at the middle row: it is identical to doing nothing at all. The UA has already centred the item, so nearest finds it on screen and does nothing — preventScroll: true is mandatory and the order is load-bearing. block: 'nearest' gives the one-row follow every quality implementation wants, and smooth is the wrong default here, so this defaults to instant.

It never writes role, and never writes selection state — no aria-selected, no aria-checked, no checked. Naming a pattern changes key handling, nothing else; your model owns the rest.

Install

npm install @ozjsey/v-keyboard-navigation

Requires Vue 3.

import { createApp } from 'vue'
import { KeyboardNavigationPlugin } from '@ozjsey/v-keyboard-navigation'
import App from './App.vue'

createApp(App).use(KeyboardNavigationPlugin).mount('#app')

Usage

A bare group

<template>
  <ul v-keyboard-navigation>
    <li><button>Bold</button></li>
    <li><button>Italic</button></li>
    <li><button>Underline</button></li>
  </ul>
</template>

That is the whole setup. Tab reaches the group once; the arrows move inside it; Home/End jump to the ends; typing a letter jumps to the matching item; the focused item is scrolled into view one step at a time. No wrapper components, no options, no composable.

The binding also accepts a bare role — v-keyboard-navigation="'menu'" is { role: 'menu' } — which is what decides the axis, whether the ends wrap, and whether aria-disabled items are skipped.

Pin the scroller

<script setup lang="ts">
const options = {
  scroll: { container: ':scope .scroll-pane', offset: { top: 48 } },
}
</script>

<template>
  <ul v-keyboard-navigation="options" role="listbox">…</ul>
</template>

A selector is always resolved against this group's host, never against the document, so a component rendered twice scrolls its own pane. offset leaves a gap for a sticky header.

Hover as an input

<script setup lang="ts">
const options = { role: 'menu', hover: true } as const
</script>

<template>
  <ul v-keyboard-navigation="options" role="menu">…</ul>
</template>

Every serious menu and combobox does this: arrow to item 3, move the mouse over item 7, press ArrowDown — you land on item 8, not item 4. It is off by default, because silently moving the active item is right for a menu and wrong for a toolbar.

Everything else

The v-keyboard-navigation playground tab is the reference: every event, every data-* state, and most options driven in a real browser and editable as you read. memory, homeEnd, behavior and the items selector form have no card yet — they are covered by the unit suite only.

CHANGELOG.md · ARCHITECTURE.md

License

MIT © Ozgur Seyidoglu

Related Packages

vue-skip-to

It helps people who only use the keyboard to jump to what matters most.

2,047 80
@4rk/vue-roving-tabindex

Add a roving tabindex to a Vue component

3,142 16
@trevoreyre/autocomplete

Simple autocomplete component in vanilla JS and Vue

62,090 437
@borstihd/vue-custom-tooltip

A flexible and accessible Vue 3 tooltip component with directive support

7,178 0
@pdanpdan/vue-keyboard-trap

Vue3 and Vue2 directive for keyboard navigation - roving movement and trapping i...

99,464 45

Version History

Version Released Vue License
0.3.2 ^3.0.0 MIT
0.3.1 ^3.0.0 MIT
0.3.0 ^3.0.0 MIT
0.1.0 ^3.0.0 MIT