npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@guillemservera/vue-details

v0.2.0

Published

Tiny composables for polished Vue interfaces.

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 the vue peer 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-details
pnpm add @guillemservera/vue-details
import { 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 carry data-index.
  • The highlighted item gets data-highlighted; the container gets data-keyboard-navigation while the keyboard owns the highlight.
  • Items that are :disabled, aria-disabled="true" or data-disabled are 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.
  • useProximityHover and useArrowNavigation return { 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 call store.suspendPointer() on every non-pointer move so a resting mouse cannot take the highlight back until it travels resumeDistance.
  • When the component writes data-highlighted itself, mirror the store into your own attribute from store.subscribe and point the indicator at it with target.
  • 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.Segmenter to 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-finite duration, blur or fade uses 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: pre and the source display: 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.
  • mod is ⌘ on macOS and iOS and Ctrl elsewhere, for app shortcuts like mod_k. meta is always the ⌘, Win or Super key; ctrl is 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 the name of the one field it works in.
  • When several bindings match, the highest priority wins; ties keep list order. enabled accepts a boolean or a getter.
  • platform is a plain override. Omit it to let shortcut matching detect the current platform; do not pass usePlatform().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/menuitem or listbox/option) and point aria-activedescendant at highlighted.
  • 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 dev

pnpm 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