@macrulez/inview-vue

Vue 3 composables for scroll, visibility and viewport-position tracking
1,014
Install
npm install @macrulez/inview-vue
Latest Version:0.3.0
Vue:3
License:MIT
Last Updated:Sep 30, 2026
Links: npm
Maintainer: 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 element
  • useElementVisibility() — reactive IntersectionObserver-backed visibility, with enter/leave edge detection and a once mode; elements sharing the same options reuse a single observer
  • useElementViewport() — an element's rect, viewportProgress (0..1 through the viewport, or a scroll container via root), and distance from its center
  • useParallaxLayer() — a ready-made "layer with its own scroll speed" primitive built on useElementViewport, disabling itself automatically under prefers-reduced-motion
  • v-reveal directive — toggles a class/attribute on an element as it enters the viewport, working inside v-for out of the box (a directive is DOM-level, not bound to setup() the way a composable is)
  • <InView> renderless component — same idea as v-reveal, but exposes the reactive isVisible/ratio back into the template via a scoped slot, for when a v-for item needs the value itself (e.g. to lazily mount a heavy child)
  • setViewportDefaults() — package-wide fallback for threshold/rootMargin/once, read by useElementVisibility, v-reveal, and <InView> alike, so you don't repeat the same options everywhere
  • useRevealController() — lifecycle wrapper around createRevealController: creates it onMounted, destroys it automatically onUnmounted
  • usePrefersReducedMotion() — reactive prefers-reduced-motion: reduce, unlike @macrulez/inview-core's static prefersReducedMotion() check
  • useStagger(), useScrollTo(), useScrollSpy() — reactive staggerDelay, scroll-to-element-with-offset, and nav-highlight/scrollspy, respectively
  • The full @macrulez/inview-core surface, re-exported — createRevealController, createScrollEngine, createVisibilityEngine, ObserverPool, staggerDelay, and the rest are all available straight from @macrulez/inview-vue too, no separate core install needed
  • SSR-safe by design — every composable subscribes lazily once its target resolves 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 you x/y/direction/progress as 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 CSS transform, at whatever relative speed you 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-for list needs a reveal effect, especially one rendered after an async fetch — useElementVisibility() is one composable per known element, called in setup() — it can't run inside a loop over data that arrives later. v-reveal is a directive, so it works directly on each v-for item 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


License

MIT


💖 Support the project

Open source takes time and effort. If this library saves you time or brings value, consider supporting further development.

Donate via CryptoCloud

Thank you for being part of this journey. ❤️

Related Packages

@macrulez/vue-form-schema

Reactive Vue 3 forms generated from JSON, Zod, Yup or Valibot schemas, with vali...

2,714 0
@macrulez/vue-state-machine

Reactive finite state machines and statecharts for Vue 3 — declarative transitio...

2,677 0
@macrulez/vue-command-palette

Headless Command+K palette for Vue 3 with fuzzy search, grouped and nested comma...

2,330 0
@macrulez/inview-nuxt

Nuxt module wrapping @macrulez/inview-vue: auto-imported composables/core engine...

963
@macrulez/masonry-kit-vue

Vue 3 component and useMasonry composable for masonry/bento grid layouts

897

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