@macrulez/inview-vue
v0.2.2
Published
Vue 3 composables for scroll, visibility and viewport-position tracking
Readme
Inview Vue

Vue 3 composables for scroll, visibility, and viewport-position
tracking, built on top of
@macrulez/inview-core.
SSR-safe: every composable is a no-op until it resolves a real DOM
element on the client.
Part of the inview monorepo.
See also
@macrulez/inview-nuxt
if you're on Nuxt, and the
playground
for a live example (scroll HUD, reveal animations, parallax layers).
Features
useScroll()— reactive scroll state (position, direction, progress, velocity,isScrolling) for the window or a scrollable elementuseElementVisibility()— reactiveIntersectionObserver-backed visibility, with enter/leave edge detection and aoncemode; elements sharing the same options reuse a single observeruseElementViewport()— an element's rect,viewportProgress(0..1 through the viewport), and distance from the viewport's centeruseParallaxLayer()— a ready-made "layer with its own scroll speed" primitive built onuseElementViewport, disabling itself automatically underprefers-reduced-motionv-revealdirective — toggles a class/attribute on an element as it enters the viewport, working insidev-forout of the box (a directive is DOM-level, not bound tosetup()the way a composable is)<InView>renderless component — same idea asv-reveal, but exposes the reactiveisVisible/ratioback into the template via a scoped slot, for when av-foritem needs the value itself (e.g. to lazily mount a heavy child)setViewportDefaults()— package-wide fallback forthreshold/rootMargin/once, read byuseElementVisibility,v-reveal, and<InView>alike, so you don't repeat the same options everywhere- The full
@macrulez/inview-coresurface, re-exported —createRevealController,createScrollEngine,createVisibilityEngine,ObserverPool,staggerDelay, and the rest are all available straight from@macrulez/inview-vuetoo, no separate core install needed - SSR-safe by design — every composable subscribes lazily once its
targetresolves to a real element on the client, no<ClientOnly>needed
When you'd reach for this
The three engines in @macrulez/inview-core with Vue's own reactivity wired on top — refs in, refs out, instead of manually subscribing and unsubscribing in onMounted/onUnmounted.
- A progress bar or scroll-driven header needs live scroll state —
useScroll()gives youx/y/direction/progressas refs that just update — no manual event listener or cleanup to write. - A card should fade in once, the first time it's seen —
useElementVisibility(el, { once: true })stops observing automatically the moment it fires, instead of you tracking "have I already shown this" yourself. - A hero section needs a background that moves slower than the foreground —
useParallaxLayer()turns scroll position into a ready CSStransform, at whatever relativespeedyou pick per layer. - The same visibility options get passed to every observed element on the page —
setViewportDefaults()sets the fallback once, instead of repeating{ threshold: 0.2, rootMargin: '-10%' }at every call site. - A
v-forlist needs a reveal effect, especially one rendered after an async fetch —useElementVisibility()is one composable per known element, called insetup()— it can't run inside a loop over data that arrives later.v-revealis a directive, so it works directly on eachv-foritem with no wrapper component. - The whole page just needs "class=reveal → animate in", without per-element JS —
createRevealController()(re-exported from@macrulez/inview-core, see its README) scans for a selector once and keeps watching for new matches — a single call instead of a composable or directive per element.
Installation
Requires Vue ^3.3.0 (for toValue/MaybeRefOrGetter).
npm install @macrulez/inview-vueQuick start
<script setup>
import { useScroll } from '@macrulez/inview-vue'
const { x, y, direction, progress, velocity, isScrolling, scrollTo } = useScroll()
// or useScroll(myContainerRef) to track a scrollable element instead of window
</script>
<template>
<div class="progress-bar" :style="{ width: `${progress * 100}%` }" />
</template>target accepts a ref, a getter, or a plain value (MaybeRefOrGetter). options.idleTimeout controls how long (ms) after the last scroll event isScrolling stays true (default 150).
More examples
useElementVisibility(target, options?)
<script setup>
import { useTemplateRef } from 'vue'
import { useElementVisibility } from '@macrulez/inview-vue'
const el = useTemplateRef('el')
const { isVisible, ratio } = useElementVisibility(el, {
threshold: 0.3,
once: true, // fire once, then stop observing — the reveal-on-scroll case
onEnter: (info) => console.log(info.edge),
})
</script>
<template>
<div ref="el" :class="{ 'is-visible': isVisible }">...</div>
</template>Elements sharing the same threshold/rootMargin/root reuse a single IntersectionObserver under the hood — you don't pay for one observer per component instance.
| Option | Default | |
| --- | --- | --- |
| threshold | 0 | number or number[] |
| rootMargin | '0px' | |
| root | viewport | MaybeRefOrGetter<HTMLElement \| null> |
| once | false | stop observing after the first intersection |
| onEnter / onLeave | — | called with { isIntersecting, intersectionRatio, boundingClientRect, edge } |
threshold/rootMargin/once fall back to viewportDefaults (see setViewportDefaults below) when omitted.
useElementViewport(target)
const { rect, viewportProgress, distanceFromCenter } = useElementViewport(el)viewportProgress (0..1) is the value most parallax effects are built on: 0 when the element just enters at the viewport's bottom edge, 1 when it leaves at the top edge.
useParallaxLayer(target, options)
<script setup>
import { useTemplateRef } from 'vue'
import { useParallaxLayer } from '@macrulez/inview-vue'
const stage = useTemplateRef('stage')
const back = useParallaxLayer(stage, { speed: 0.2 })
const front = useParallaxLayer(stage, { speed: 1 })
</script>
<template>
<div ref="stage" class="stage">
<div class="layer" :style="back.style.value">back</div>
<div class="layer" :style="front.style.value">front</div>
</div>
</template>| Option | Default | |
| --- | --- | --- |
| speed | required | 1 moves with scroll, >1 faster, <1 slower, negative reverses direction |
| axis | 'y' | 'x' \| 'y' |
| range | 100 | px offset amplitude at speed: 1 |
| clamp | false | clamp the offset at the 0/1 progress edges instead of extrapolating |
| easing | — | applied to viewportProgress before mapping it to an offset — see easings from @macrulez/inview-core |
setViewportDefaults / viewportDefaults
Package-wide fallback for threshold/rootMargin/once, read by useElementVisibility, v-reveal, and <InView> alike, so you don't have to pass the same options to every call site. This is what
@macrulez/inview-nuxt's
module options configure under the hood — call it directly if you're not using Nuxt:
import { setViewportDefaults } from '@macrulez/inview-vue'
setViewportDefaults({ threshold: 0.2, rootMargin: '-10%', once: true })v-reveal
<template>
<div v-for="item in items" :key="item.id" v-reveal.once class="card">
{{ item.title }}
</div>
</template>Toggles a class (default "in") and/or a data-attribute on the bound element as it enters the viewport. v-reveal.once is shorthand for v-reveal="{ once: true }"; pass an object for the rest:
<div v-reveal="{ once: true, threshold: 0.3, class: 'visible', onEnter: (info) => track(item.id) }">| Option | Default | |
| --- | --- | --- |
| class | 'in' | pass null to disable the class toggle |
| attribute | — | boolean data-attribute set alongside class |
| once | viewportDefaults.once | also settable via the .once modifier |
| threshold / rootMargin / root | viewportDefaults | same meaning as useElementVisibility |
| onEnter / onLeave | — | called with { isIntersecting, intersectionRatio, boundingClientRect, edge } |
Unlike a per-v-for-item composable (not actually possible — composables are called once in setup(), not per loop iteration), a directive is DOM-level and just works on however many elements render, including ones added later by the same v-for.
A fresh inline options object on every re-render (the common case — v-reveal="{ once: true, onEnter: ... }" builds a new object literal each time) doesn't force a re-subscribe: class/attribute/once/threshold/rootMargin/root are compared by value (a threshold array by its joined contents), not by reference, and onEnter/onLeave identity is ignored entirely — the directive only unsubscribes and resubscribes when one of those values actually changed.
For a page-wide pass instead of a directive on every element, see createRevealController in the "Low-level utilities" section below.
<InView>
Renderless wrapper around useElementVisibility for when a v-for item needs the reactive value itself, not just a class toggle — e.g. to lazily mount a heavy child:
<template>
<InView v-for="item in items" :key="item.id" once v-slot="{ isVisible }">
<HeavyChart v-if="isVisible" :item="item" />
</InView>
</template>Props: once, threshold, rootMargin, root (same as useElementVisibility), and as (the wrapping tag, default 'div' — an IntersectionObserver needs a real element to measure, so <InView> always renders one). Emits enter/leave with the same info object onEnter/onLeave receive elsewhere.
Low-level utilities
Every @macrulez/inview-core export is re-exported here too — createScrollEngine, createVisibilityEngine, createElementTracker, createRevealController, ObserverPool/observerPool, rafLoop, mapRange, bindCSSVar, prefersReducedMotion, clamp, staggerDelay, easings, and their types. Reach for createRevealController directly (rather than v-reveal per element) for a page-wide "every .reveal/[data-reveal] element, including ones that don't exist yet" pass — see
@macrulez/inview-core's README
for its full option list and every other signature here.
Documentation & links
- 📖 Full documentation: npm.vuecraft.ru/en/packages/inview
- 🌐 VueCraft: vuecraft.ru/en
- 👤 Author: macrulez.ru/en
- 💻 GitHub: macrulezru/inview/packages/vue
- 📦 NPM: @macrulez/inview-vue
- 🐛 Issues: github.com/macrulezru/inview/issues
License
MIT
💖 Support the project
Open source takes time and effort. If this library saves you time or brings value, consider supporting further development.
Thank you for being part of this journey. ❤️
