@guillemservera/vue-details
v0.2.0
Published
Tiny composables for polished Vue interfaces.
Maintainers
Readme
vue-details
Tiny composables for polished Vue interfaces.
The little interaction details Vue doesn't handle for you: proximity hover, keyboard navigation, gliding indicators and text transitions. You own the markup and styles; the composables handle the behavior.
Status
vue-details is in an early 0.x release. The API may change before 1.0.
Features
- Vue 3.5+ composables built on
@guillemservera/details-core, with no third-party runtime dependencies beyond thevuepeer dependency. - ESM only,
sideEffects: false, one subpath per composable: import one and bundle only that one. - No CSS files, no visual classes; state is exposed through
data-*attributes. - SSR safe: no DOM access during setup.
- Handles late-mounted and replaced template refs, and cleans up on scope disposal.
- Honors
prefers-reduced-motion.
Installation
npm install @guillemservera/vue-detailspnpm add @guillemservera/vue-detailsimport { useProximityHover } from '@guillemservera/vue-details'
// or one subpath at a time
import { useProximityHover } from '@guillemservera/vue-details/proximity-hover'| Composable | Subpath | What it does |
| --- | --- | --- |
| useProximityHover | /proximity-hover | Highlights the item under the pointer, or the nearest one in gaps and padding. |
| useArrowNavigation | /arrow-navigation | Arrow keys, Home, End and Enter move and activate the highlight; works while hovered. |
| useHighlightIndicator | /highlight-indicator | Springs one indicator onto the highlighted (or selected) item, with no renders per frame. |
| useRollingText | /rolling-text | Rolls complete words or phrases as single rows, without splitting them into characters. |
| useMorphText | /morph-text | Blends complete phrases through blur and crossfade, preserving in-flight opacity and blur when interrupted. |
| useRouletteText | /roulette-text | Rolls individual graphemes through vertical slots, with optional blur and edge fade. |
| useShortcuts | /shortcuts | Keyboard shortcuts and sequences, with platform-aware mod and labels. |
| usePlatform | /platform | The operating system, detected after mount so hydration matches. |
| useInputCapabilities | /input-capabilities | Hover, pointer precision and touch, kept current from media queries. |
Quick Start
A list where the nearest item is always highlighted, an indicator glides to it, and the arrow keys take over:
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { useArrowNavigation, useHighlightIndicator, useProximityHover } from '@guillemservera/vue-details'
const items = ['Inbox', 'Drafts', 'Sent', 'Archive']
const list = useTemplateRef<HTMLElement>('list')
const { highlighted } = useProximityHover(list)
useArrowNavigation(list)
useHighlightIndicator(list, useTemplateRef('indicator'))
</script>
<template>
<div
ref="list"
class="list"
role="menu"
tabindex="0"
:aria-activedescendant="highlighted?.id"
>
<div ref="indicator" class="indicator" aria-hidden="true" />
<button
v-for="(item, i) in items"
:id="`menu-item-${i}`"
:key="item"
data-highlight-item
role="menuitem"
tabindex="-1"
>
{{ item }}
</button>
</div>
</template>
<style>
.list { position: relative; }
.indicator { position: absolute; top: 0; left: 0; border-radius: 8px; background: #0000000d; pointer-events: none; }
button { position: relative; }
</style>Highlight composables
useProximityHover, useArrowNavigation and useHighlightIndicator work alone or together. Pass the same container ref to combine them: they share one highlight store. If the container is replaced, the ref still identifies the same store, but selection is not guaranteed to survive: state resets when the last container detaches, and removed DOM elements are never retained.
- Items carry
data-highlight-item. In virtualized lists they also carrydata-index. - The highlighted item gets
data-highlighted; the container getsdata-keyboard-navigationwhile the keyboard owns the highlight. - Items that are
:disabled,aria-disabled="true"ordata-disabledare skipped. - Touch input is ignored: there is no hover on touch screens, so taps and swipes never leave a highlight behind. Focus and keyboard still highlight.
useProximityHoveranduseArrowNavigationreturn{ highlighted, source }: the highlighted element and what highlighted it ('pointer' | 'keyboard' | 'focus').
useProximityHover(container, options?)
The item under the pointer is highlighted in the pointer event itself, like native :hover; in gaps and padding, the nearest one is, from measured geometry. While content scrolls under a still pointer, the highlight follows at once. Items mostly clipped at a scroll edge only win when nothing better is visible.
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| axis | 'x' \| 'y' \| 'xy' | 'y' | Axis the nearest item is measured along: lists, strips, or grids and wrapping rows. |
| resumeDistance | MaybeRefOrGetter<number> | 6 | Pixels the mouse must travel before it takes the highlight back from the keyboard. |
| gapClick | MaybeRefOrGetter<boolean> | true | A click in a gap between items clicks the highlighted item. |
| ignore | string | — | Selector of non-item content, such as group labels: the pointer over it highlights nothing, where a gap takes the nearest item. |
Besides { highlighted, source }, it returns remeasure(): items are measured again when they resize, when the DOM changes and when an animation or transform transition on an ancestor ends (an enter zoom); call it for any other geometry change that has no DOM mutation.
useArrowNavigation(container, options?)
↑ ↓ (or ← → on x), Home and End move the highlight; Enter and Space activate the highlighted item. Navigation keeps focus on the container, so give it tabindex="0" (React uses tabIndex={0}); items can use tabindex="-1" when the container owns focus. Only the container scrolls, never the page. Used alone, the item under the mouse is highlighted like plain hover, so keys continue from it.
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| axis | 'x' \| 'y' \| 'xy' | 'y' | Which arrow keys navigate. |
| loop | MaybeRefOrGetter<boolean> | false | Wrap from the last item to the first and back. |
| whileHovered | MaybeRefOrGetter<boolean> | true | Keys also work while the pointer is over the container, before it has focus. |
| resumeDistance | MaybeRefOrGetter<number> | 6 | Pixels the mouse must travel before hover takes the highlight back. |
| count | MaybeRefOrGetter<number \| undefined> | — | Total items of a virtualized list; indexes belong on the actual highlighted rows. |
| scrollToIndex | (index: number) => void | — | Brings an unrendered item of a virtualized list into view, e.g. TanStack Virtual's scrollToIndex. |
| isDisabled | (index: number) => boolean | — | Skips disabled indexes throughout the virtual model, including unmounted rows. |
| focusTarget | MaybeRefOrGetter<HTMLElement \| null> | — | Explicit search input whose focus is retained during navigation. |
| currentIndex | MaybeRefOrGetter<number \| undefined> | — | Consumer-owned index to navigate from. |
| onIndexChange | (index: number) => void | — | Updates the consumer's index after keyboard navigation. |
| store | HighlightStore | Shared by container ref | Shares state explicitly when an integration uses a different container ref. |
Modified keys and IME composition are ignored. Keys typed into editable elements are ignored unless the element is the explicit focusTarget; that input keeps native Home, End and Space behavior, while arrows navigate and Enter activates the rendered highlighted row.
useHighlightIndicator(container, indicator, options?)
Springs an absolutely positioned indicator onto the item matching target, whoever marks it: the pointer, the keyboard, v-model or a headless menu. The container is position: relative and may scroll; the indicator is position: absolute; top: 0; left: 0. Transform, size and opacity are written directly, and the glide runs as a Web Animation on the compositor (falling back automatically to per-frame writes where Element.animate is missing), with no component renders per frame. With reduced motion it fades without travelling.
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| target | string | '[data-highlighted]' | Selector of the item to sit on, e.g. '[aria-selected="true"]' for the active tab. |
| from | string | — | A new session grows out of this item and returns into it when it ends. |
| motion | 'fast' \| 'smooth' \| 'moderate' | 'smooth' | Critically damped spring of 80, 100 or 160 ms. |
| reducedMotion | MaybeRefOrGetter<boolean \| undefined> | OS setting | Jump instead of gliding; fades still run. Read on every update, so an app setting can drive it; undefined follows prefers-reduced-motion. |
It returns { remeasure, freeze }. remeasure() measures again after geometry changes without a DOM mutation. freeze() stops observation and animation while retaining the current rendered paint for an exit animation; it is terminal for that instance. Normal element replacement or scope disposal cleans up and restores the original styles.
A tab bar with a sliding selection pill:
useHighlightIndicator(tabs, pill, { target: '[aria-selected="true"]', motion: 'moderate' })useHighlightStore(container)
The store that the highlight composables receiving container share, with its state: { store, highlighted, source }. Exported from /proximity-hover, /arrow-navigation, /highlight-indicator and the package root. Use it when a component owns its own highlight model (a combobox, a headless menu):
- Mirror the component's active item with
store.highlight(item, 'keyboard'), and callstore.suspendPointer()on every non-pointer move so a resting mouse cannot take the highlight back until it travelsresumeDistance. - When the component writes
data-highlighteditself, mirror the store into your own attribute fromstore.subscribeand point the indicator at it withtarget. - To pin the indicator on screen while your own code scrolls to reveal an item, measure the scroll around it and set
store.nudge = { item, dx, dy }before the highlight reaches the DOM. - To keep the indicator's current frame for an exit animation, call the indicator composable's
freeze()before the content is torn down. Normal disposal restores its original styles.
Call it during setup or inside an effect scope. It attaches the store to the container even without pointer or keyboard composables, so store.items() and store.highlightIndex() work independently. Removed items are reconciled, and the attachment is released on container replacement or scope disposal.
Text effects
useRollingText, useMorphText and useRouletteText take a Vue-owned source span and an empty sibling viewport. The composable draws the effect in the viewport and hides the source visually, not from assistive technology. Source text renders during SSR, and changes are picked up after Vue patches it.
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { useMorphText } from '@guillemservera/vue-details/morph-text'
const text = ref('Make every word count.')
const blur = ref(0.2)
const source = useTemplateRef<HTMLElement>('source')
const viewport = useTemplateRef<HTMLElement>('viewport')
useMorphText(source, viewport, { duration: 600, blur })
</script>
<template>
<span style="position: relative; display: inline-block; white-space: pre">
<span ref="source" style="display: inline-block; min-height: 1lh">{{ text }}</span>
<span ref="viewport" aria-hidden="true"></span>
</span>
</template>The other two effects use the same markup:
import { useRollingText } from '@guillemservera/vue-details/rolling-text'
import { useRouletteText } from '@guillemservera/vue-details/roulette-text'
useRollingText(source, viewport, { direction: 'up' })
useRouletteText(source, viewport, { blur: 0.08, fade: 0.4 }) // No blur or fade by default.Use one effect per source and viewport pair.
| Option | Type | Rolling | Morph | Roulette |
| --- | --- | --- | --- | --- |
| duration | MaybeRefOrGetter<number> | 600 | 600 | 600 |
| animated | MaybeRefOrGetter<boolean> | true | true | true |
| direction | MaybeRefOrGetter<'up' \| 'down'> | 'up' | — | 'up' |
| blur | MaybeRefOrGetter<number> | — | 0.2 em; 0 is a sharp crossfade | 0 em; peak blur while a slot rolls |
| fade | MaybeRefOrGetter<number> | — | — | 0; from 0 to 1, how much of the slot edges fades |
| alphabet | MaybeRefOrGetter<string> | — | — | 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; targets need not be in it |
- Rolling moves each phrase as one row; interruptions continue from the visible position.
- Morph is a blur and crossfade, not an interpolation of letter outlines. Rapid updates keep at most two fading layers.
- Roulette uses
Intl.Segmenterto keep emoji and combining sequences whole, animates only the characters that change, and renders the exact target string when settled. - Changing an option settles the current effect.
animated: false, a zero duration, reduced motion and hidden documents apply text instantly. A negative or non-finiteduration,blurorfadeuses the default. - Right-to-left text and padding or borders on the parent are supported.
Constraints:
- Single line only. The source must be plain text and the viewport must stay empty in your template.
- The parent holds only the source and the viewport, is
position: relative; display: inline-block, and carries the typography. - Give the parent
white-space: preand the sourcedisplay: inline-block; min-height: 1lh, as above. The composable applies them on mount; without them in your markup, server-rendered empty text or leading and trailing spaces shift on hydration. - Roulette draws each grapheme in its own slot, so while it rolls, Arabic and other cursive scripts lose letter joining, and ligatures and kerning are lost. The settled text renders normally.
- Parent padding is read when the text, its size or an option changes.
- The effects require the Web Animations API; roulette also requires
Intl.Segmenter.
Keyboard and device
useShortcuts, usePlatform and useInputCapabilities render the server defaults during SSR and hydration, and read the browser after mount.
useShortcuts(bindings, options?)
Runs the matching binding on keydown. bindings accepts an array, ref or getter and is read on every keystroke, so bindings can change at any time. The listener attaches after mount, moves when target changes (clearing a partial sequence) and is removed on scope disposal.
<script setup lang="ts">
import { ref } from 'vue'
import { formatShortcutLabel, usePlatform, useShortcuts } from '@guillemservera/vue-details'
const open = ref(false)
const platform = usePlatform()
useShortcuts([
{ keys: ['mod_k', '/'], handler: () => (open.value = true) },
{ keys: 'escape', handler: () => (open.value = false), usingInput: true, enabled: () => open.value },
{ keys: 'g-d', handler: () => navigateTo('/dashboard') },
])
</script>
<template>
<button @click="open = true">
Search <kbd>{{ formatShortcutLabel('mod_k', { platform }) }}</kbd>
</button>
</template>- Keys are joined with
_for combinations (mod_shift_p) and-for sequences (g-d); pass an array for alternatives. modis ⌘ on macOS and iOS and Ctrl elsewhere, for app shortcuts likemod_k.metais always the ⌘, Win or Super key;ctrlis always Control.- Symbols such as
?match whatever Shift the layout needs to type them. - Keys typed into inputs, textareas, selects and content-editable elements are ignored unless the binding sets
usingInput:true, or thenameof the one field it works in. - When several bindings match, the highest
prioritywins; ties keep list order.enabledaccepts a boolean or a getter. platformis a plain override. Omit it to let shortcut matching detect the current platform; do not passusePlatform().value, which starts as'unknown'and is not reactive through this option. Use the platform ref for reactive label formatting instead.
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| target | MaybeRefOrGetter<EventTarget \| null \| undefined> | window | Where to listen. A ref holding null listens nowhere. |
| capture | boolean | true | Listen in the capture phase, before handlers inside the page. |
| platform | Platform | detected | Overrides how mod matches; meta always means the Meta key. |
| sequenceTimeoutMs | number | 900 | How long a partial sequence stays live. |
| preventDefault | boolean | true | Call preventDefault() on handled keys. |
| respectDefaultPrevented | boolean | true | Skip events another listener already claimed. |
| shouldHandle | (event, { binding, key }) => boolean | — | Final veto after a binding matched. |
/shortcuts also exports the label formatters from @guillemservera/details-core, so Vue consumers can import them from the Vue package:
formatShortcutLabel('mod_shift_p') // ⌘⇧P on Apple, Ctrl+Shift+P elsewhere
formatShortcutLabel(['mod_k', '/']) // ⌘K or /
formatShortcutLabel('g-d', { thenLabel: 'luego' }) // Localized sequence separator
formatShortcutTokens('mod_k') // [{ kind: 'key', key: 'mod', label: '⌘' }, …], for rendering one <kbd> per key
formatShortcutKey('arrowup') // ↑Without platform, the formatters detect it on every call, which differs between server and client. In SSR apps pass usePlatform()'s value, as above: labels render as non-Apple on the server and switch after mount.
usePlatform()
Returns a read-only ref: 'mac' | 'ios' | 'windows' | 'linux' | 'android' | 'chromeos' | 'unknown'. It is 'unknown' on the server and until mount, then the detected platform, so the first client render matches the server HTML. iPadOS is reported as 'ios'.
import { computed } from 'vue'
import { isApplePlatform, usePlatform } from '@guillemservera/vue-details/platform'
const platform = usePlatform()
const isApple = computed(() => isApplePlatform(platform.value))useInputCapabilities()
Returns a read-only shallow ref with what the device's pointers can do. Every field is false on the server and until mount. All instances share one set of media query listeners, removed when the last instance is disposed. The object is replaced only when a value changes.
| Field | Source | Description |
| --- | --- | --- |
| canHover | (any-hover: hover) | Some available pointer can hover. |
| hasFinePointer | (any-pointer: fine) | Some available pointer is precise. |
| primaryPointerIsCoarse | (pointer: coarse) | The primary pointer is a finger or a stylus. |
| hasTouch | navigator.maxTouchPoints > 0 | A touch screen is present, even on a laptop with a mouse. |
| isTouchFirst | coarse and no hover | Hover-only affordances are unreachable; show them another way. |
<script setup lang="ts">
import { useInputCapabilities } from '@guillemservera/vue-details/input-capabilities'
const input = useInputCapabilities()
</script>
<template>
<button :data-always-visible="input.isTouchFirst || undefined">Delete</button>
</template>Prefer CSS media queries for pure styling; use this when the behavior or markup changes.
Accessibility
- The composables add behavior, not semantics. Give your widget the roles it needs (for example
menu/menuitemorlistbox/option) and pointaria-activedescendantathighlighted. - Keyboard navigation keeps focus on the container, so virtualized items can unmount without losing focus.
- Indicators are decorative: mark them
aria-hidden="true". - Text effects keep the source text in the accessibility tree and hide only their visual viewport.
Nuxt and SSR
All composables are safe to call during server rendering: they only touch the DOM after mount. Text effects render the source text on the server.
Known issues
Rows shimmer by 1px while keyboard navigation scrolls at fractional device pixel ratios
At a device pixel ratio such as 1.1 or 1.375 (display scaling combined with browser zoom), a row pitch like 44 CSS px is 60.5 device px. Rows therefore sit alternately on whole and half device pixels, while scroll offsets always snap to whole device pixels. Each step that scrolls the list lands the highlighted row half a device pixel higher or lower than the previous one, and text snapping turns that into a visible ±1px shimmer at the top and bottom edges. Nothing moves in the middle of the list, because nothing scrolls there.
This is not caused by the composables: native scrollIntoView({ block: 'nearest' }) on the same markup flickers identically, and it disappears at ratios where the row pitch is a whole number of device pixels (1, 1.25 and 1.5 for 44px rows).
Mitigations are on the app side: a row pitch (height + gap) that is a whole number of device pixels at the ratios you target (for example 48px at 1.375), or 100% browser zoom.
Local Development
This package lives in the details monorepo, next to @guillemservera/details-core and @guillemservera/react-details. From the repository root:
pnpm install
pnpm devpnpm dev starts the local demo site. It imports the package sources directly, so nothing needs building first.
Commands inside packages/vue:
| Command | What it runs |
| --- | --- |
| pnpm test | Unit tests |
| pnpm test:browser | Browser tests in Chrome |
| pnpm typecheck | Type checking |
| pnpm size | Build and per-entry gzip budgets, core included |
| pnpm run ci | Everything CI runs for this package |
Credits
The proximity hover idea and spring tiers follow Fluid Functionalism (MIT).
License
MIT © 2026 Guillem Servera
