@macrulez/inview-vue
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, or a scroll container viaroot), and distance from its 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 everywhereuseRevealController()— lifecycle wrapper aroundcreateRevealController: creates itonMounted, destroys it automaticallyonUnmountedusePrefersReducedMotion()— reactiveprefers-reduced-motion: reduce, unlike@macrulez/inview-core's staticprefersReducedMotion()checkuseStagger(),useScrollTo(),useScrollSpy()— reactivestaggerDelay, scroll-to-element-with-offset, and nav-highlight/scrollspy, respectively- 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-vue
Quick 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 |
MaybeRefOrGetter<number | number[]> — changing it re-subscribes |
rootMargin |
'0px' |
MaybeRefOrGetter<string> — changing it re-subscribes |
root |
viewport | MaybeRefOrGetter<HTMLElement | null> — changing it re-subscribes |
once |
false |
MaybeRefOrGetter<boolean> — updates live, without re-subscribing |
onEnter / onLeave |
— | called with { isIntersecting, intersectionRatio, boundingClientRect, edge } — plain callbacks, not ref-wrapped (see below) |
threshold/rootMargin/once fall back to viewportDefaults (see setViewportDefaults below) when omitted.
threshold/rootMargin/root determine which pooled IntersectionObserver this subscribes to, so changing any of them is a real teardown-and-resubscribe — there's no way around that with a pooled observer. once isn't part of that pool key, so passing it as a ref updates the live subscription in place instead:
const revealOnce = ref(false)
useElementVisibility(el, { once: revealOnce }) // toggling revealOnce.value later just works, no re-subscribe
onEnter/onLeave are deliberately not reactive-wrapped — unlike once, a callback that closes over a ref's .value already reads it fresh every time it fires, so there's nothing for a MaybeRefOrGetter to add (and wrapping a function in one would be ambiguous with passing a getter for the callback itself). Same convention v-reveal already documents for its own onEnter/onLeave.
useElementViewport(target, options?)
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 tracking frame's bottom edge, 1 when it leaves at the top edge. That frame is the window by default; pass root to measure against a scrollable container instead, for an element inside overflow: auto (a dashboard panel, a modal):
const { viewportProgress } = useElementViewport(el, { root: scrollContainer })
rect stays the element's plain, window-relative getBoundingClientRect() regardless of root.
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 |
root |
window | measure viewportProgress against this scroll container instead of the window, same as useElementViewport's own root |
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.
useRevealController(options?)
Lifecycle wrapper around createRevealController — creates it onMounted (so the component's own .reveal/[data-reveal] elements already exist in the DOM to scan) and destroys it automatically onUnmounted, removing the manual onMounted/onUnmounted boilerplate a page-wide reveal controller otherwise needs:
import { useRevealController } from '@macrulez/inview-vue'
useRevealController({ stagger: { step: 70, max: 4 } })
Returns { refresh(selector?), observe(el), unobserve(el), destroy() }, same as createRevealController itself. options are captured once and used for the controller's whole lifetime — not a reactive ref/getter.
usePrefersReducedMotion()
Reactive prefers-reduced-motion: reduce, unlike @macrulez/inview-core's static prefersReducedMotion() (a one-shot matchMedia().matches read that won't notice the OS setting changing mid-session unless something else happens to re-invoke it):
import { usePrefersReducedMotion } from '@macrulez/inview-vue'
const prefersReducedMotion = usePrefersReducedMotion() // Ref<boolean>, updates live
useStagger(index, options?)
Reactive wrapper around staggerDelay — recomputes when index/options (passed as a ref/getter) change, instead of calling staggerDelay by hand in a hand-rolled v-for reveal effect:
import { useStagger } from '@macrulez/inview-vue'
const delay = useStagger(index, { step: 70, max: 4 }) // ComputedRef<string>
useScrollTo(target?)
Returns a scrollTo(el, options?) callback that smooth-scrolls target (default window) so el lands at its edge — the "scroll to an element, with an offset" case useScroll()'s own scrollTo doesn't cover (that one only takes a raw x/y position):
import { useScrollTo } from '@macrulez/inview-vue'
const scrollTo = useScrollTo()
scrollTo(sectionEl.value, { offset: 80 }) // e.g. clearing a sticky header
useScrollSpy(targets, options?)
Reactively tracks which of targets currently sits in the "active" band (see createScrollSpy in the core README) and returns its index — the nav-highlight case, built entirely on the pooled visibility engine:
<script setup>
import { useTemplateRef } from 'vue'
import { useScrollSpy } from '@macrulez/inview-vue'
const sections = useTemplateRef('sections') // an array of elements from v-for
const activeIndex = useScrollSpy(sections)
</script>
<template>
<nav>
<a v-for="(section, i) in sections" :key="i" :class="{ active: activeIndex === i }">...</a>
</nav>
</template>
A reading-progress bar, from useScroll()
Not its own hook — useScroll().progress (0..1) already is the value, so a progress bar is just:
<script setup>
import { useScroll } from '@macrulez/inview-vue'
const { progress } = useScroll()
</script>
<template>
<div class="progress-bar" :style="{ transform: `scaleX(${progress})` }" />
</template>
Low-level utilities
Every @macrulez/inview-core export is re-exported here too — createScrollEngine, createVisibilityEngine, createElementTracker, createRevealController, createScrollSpy, scrollToElement, 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. ❤️
Related Packages
Reactive Vue 3 forms generated from JSON, Zod, Yup or Valibot schemas, with vali...
Reactive finite state machines and statecharts for Vue 3 — declarative transitio...
Headless Command+K palette for Vue 3 with fuzzy search, grouped and nested comma...
Nuxt module wrapping @macrulez/inview-vue: auto-imported composables/core engine...
Vue 3 component and useMasonry composable for masonry/bento grid layouts
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.3.0 | ^3.3.0 | MIT | |
| 0.2.3 | ^3.3.0 | MIT | |
| 0.2.2 | ^3.3.0 | MIT | |
| 0.2.1 | ^3.3.0 | MIT | |
| 0.2.0 | ^3.3.0 | MIT | |
| 0.1.0 | ^3.3.0 | MIT |