Skip to content

React Hooks ​

@macrulez/inview-react gives the same core capabilities as the Vue composables, built on useSyncExternalStore on top of the same framework-agnostic core engines — same API, same behavior, mirrored 1:1 — plus its own <InView> render-prop component and an <InviewProvider> for app-level defaults, the React equivalents of Vue's v-reveal/<InView> and viewportDefaults/setViewportDefaults(). Import everything from @macrulez/inview-react.

The differences from Vue are structural, not behavioral: plain values instead of Refs (no .value), a plain HTMLElement | null for target instead of MaybeRefOrGetter (React has no ref-as-reactive primitive to accept there — use a state-backed callback ref, not a plain useRef, if target needs to change after mount), and a static snapshot returned on the server via getServerSnapshot. For what each field actually means, see the linked core/Vue pages below — this page only calls out what's React-specific.

@macrulez/inview-react also re-exports the full @macrulez/inview-core surface, including the primitives with their own dedicated hooks below (useReveal, useRevealController, usePrefersReducedMotion, useStagger, useScrollTo, useScrollSpy).

Reactive Scroll State ​

useScroll() — the React equivalent of Reactive Scroll State.

ts
function useScroll(
  target?: Window | HTMLElement | null,
  options?: UseScrollOptions,
): UseScrollReturn

Same fields (x, y, direction, progress, velocity, isScrolling, scrollTo), as plain values instead of refs. options.idleTimeout (default 150) works the same way.

Return value ​

FieldType
x, ynumber
direction'up' | 'down' | 'left' | 'right' | null
progress, velocitynumber
isScrollingboolean
scrollTo(position: number | { x?: number; y?: number }, options?: ScrollToOptions) => void

Example:

tsx
import { useScroll } from '@macrulez/inview-react'

function ScrollIndicator() {
  const { y, isScrolling } = useScroll()
  return isScrolling ? <p>{y}px</p> : null
}

Element Visibility ​

useElementVisibility() — the React equivalent of Element Visibility.

ts
function useElementVisibility(
  target: HTMLElement | null,
  options?: UseElementVisibilityOptions,
): UseElementVisibilityReturn

Uses the same shared, pooled visibility engine. options — threshold, rootMargin, once, onEnter, onLeave — same shape as Vue, except root: HTMLElement | null (a plain value, not MaybeRefOrGetter). Changing target/threshold/rootMargin/root re-subscribes for real (pool membership is keyed on them); once, onEnter, and onLeave update the live subscription instead — every render passes fresh closures, so onEnter/onLeave never go stale the way a naive useEffect([]) version would. A fresh [0, 0.5] array literal for threshold on every render doesn't force a re-subscribe either — it's compared by value, not by reference.

threshold/rootMargin/once fall back to <InviewProvider>'s defaults when this hook runs inside one, and to 0/'0px'/false otherwise — the React equivalent of Vue's viewportDefaults, just read from context instead of a module-level object. An option passed directly to the hook always wins over the provider.

Return value ​

FieldType
isVisibleboolean
rationumber

Reveal (useReveal) ​

useReveal() — the React equivalent of v-reveal: wraps useElementVisibility and additionally toggles a class (default 'in') and/or a data-attribute on target as it enters the viewport, for the common "just flip a class" case without hand-rolling classList.toggle in an effect. Reach for useElementVisibility directly for anything more custom than a class/attribute toggle.

ts
function useReveal(target: HTMLElement | null, options?: UseRevealOptions): UseRevealReturn

Options ​

FieldTypeDefault
activeClassstring | null'in' — pass null to disable
activeAttributestring | nullnull
oncebooleanfalse
thresholdnumber | number[]—
rootMarginstring—
rootHTMLElement | null—
onEnter / onLeave(info: IntersectionInfo) => void—

Return value ​

Same shape as Element Visibility above — isVisible, ratio.

tsx
import { useState } from 'react'
import { useReveal } from '@macrulez/inview-react'

function Card() {
  const [el, setEl] = useState<HTMLElement | null>(null)
  useReveal(el, { once: true })
  return (
    <div ref={setEl} className="reveal">
      ...
    </div>
  )
}

App-Level Defaults (<InviewProvider>) ​

<InviewProvider> / useInviewDefaults() — app-level threshold/rootMargin/once defaults, read by useElementVisibility() and <InView> alike, so they don't have to be repeated at every call site. The React equivalent of the Vue adapter's viewportDefaults/setViewportDefaults() — context instead of a shared module-level object.

tsx
import { InviewProvider } from '@macrulez/inview-react'

function App() {
  return (
    <InviewProvider defaults={{ threshold: 0.2, once: true }}>
      <Page />
    </InviewProvider>
  )
}

Any useElementVisibility(el, { threshold: 0.6 }) call inside <Page> still gets threshold: 0.6 — an option passed directly to the hook always wins over the provider. useInviewDefaults() reads the same merged value directly, for a custom component that wants the resolved defaults without going through useElementVisibility.

Props ​

defaults ​

Partial<{ threshold: number | number[]; rootMargin: string; once: boolean }> · default: {}

Merged on top of the package-wide defaults (threshold: 0, rootMargin: '0px', once: false) — only the fields provided override them.

useInviewDefaults() ​

ts
function useInviewDefaults(): { threshold: number | number[]; rootMargin: string; once: boolean }

Returns the current <InviewProvider> defaults, or the package-wide defaults when called outside one — so existing code with no provider in the tree keeps behaving exactly as before.

<InView> ​

Render-prop wrapper around useElementVisibility for when the element being measured is the one whose render depends on the result — e.g. lazily mounting a heavy child once it's visible — where a useState-backed callback ref would just be extra boilerplate around the same thing. Not the same shape as Vue's <InView>: children is a function here (a render prop), not a scoped slot.

tsx
import { InView } from '@macrulez/inview-react'

;<InView once>{({ isVisible }) => (isVisible ? <Heavy /> : null)}</InView>

Props ​

FieldTypeDefault
oncebooleanfalls back to <InviewProvider>'s default
thresholdnumber | number[]falls back to <InviewProvider>'s default
rootMarginstringfalls back to <InviewProvider>'s default
rootHTMLElement | nullnull
astag name (keyof JSX.IntrinsicElements)'div' — the measured wrapper element
onEnter / onLeave(info: IntersectionInfo) => void—
children(state: { isVisible: boolean; ratio: number }) => ReactNoderequired — the render prop

Always renders one real wrapping element (as, default 'div') — an IntersectionObserver needs an actual element to measure.

Element Viewport Position ​

useElementViewport() — the React equivalent of Element Viewport Position.

ts
function useElementViewport(
  target: HTMLElement | null,
  options?: UseElementViewportOptions,
): UseElementViewportReturn

Returns the same shape — rect, viewportProgress, distanceFromCenter — as plain values. options.root (default null, the window) measures viewportProgress/distanceFromCenter relative to a scrollable container instead — rect itself always stays window-relative either way.

Parallax Layer ​

useParallaxLayer() — the React equivalent of Parallax Layer.

ts
function useParallaxLayer(
  target: HTMLElement | null,
  options: UseParallaxLayerOptions,
): UseParallaxLayerReturn

Same options (speed required, axis, clamp, easing, range, root), same mapping logic, same prefers-reduced-motion behavior. style/progress are memoized with useMemo rather than being a Vue computed.

Return value ​

FieldType
style{ transform: string }
progressnumber

Example:

tsx
import { useParallaxLayer } from '@macrulez/inview-react'

function ParallaxImage() {
  const ref = useRef<HTMLImageElement | null>(null)
  const { style } = useParallaxLayer(ref.current, { speed: 0.6 })
  return <img ref={ref} style={style} src="/hero.jpg" />
}

Reveal Controller (useRevealController) ​

useRevealController() — the React equivalent of useRevealController: a lifecycle wrapper around createRevealController that creates the controller in an effect and destroys it automatically on unmount.

ts
function useRevealController(options?: RevealControllerOptions): RevealController
tsx
import { useRevealController } from '@macrulez/inview-react'

function App() {
  useRevealController({ stagger: { step: 70, max: 4 } })
  return <>{/* ...cards rendered with className="reveal", including ones from an async fetch */}</>
}

options are captured once, at the first render, and used for the controller's entire lifetime — the same one-shot option model createRevealController itself has. The returned refresh/observe/unobserve/destroy methods proxy to the controller instance once the mount effect has run; call them from an event handler or another effect, not from the render body itself.

Calling createRevealController directly, in your own effect, still works exactly as before for anything the wrapper doesn't cover:

tsx
import { useEffect } from 'react'
import { createRevealController } from '@macrulez/inview-react'

function App() {
  useEffect(() => {
    const controller = createRevealController({ stagger: { step: 70, max: 4 } })
    return () => controller.destroy()
  }, [])

  return <>{/* ... */}</>
}

Reduced Motion Preference (usePrefersReducedMotion) ​

usePrefersReducedMotion() — reactive prefers-reduced-motion: reduce, unlike prefersReducedMotion()'s static one-shot check. Built on useSyncExternalStore, subscribing to the media query's own change event; returns false on the server.

ts
function usePrefersReducedMotion(): boolean
tsx
import { usePrefersReducedMotion } from '@macrulez/inview-react'

function Animated() {
  const reduced = usePrefersReducedMotion()
  return <div className={reduced ? undefined : 'spin'} />
}

Stagger (useStagger) ​

useStagger() — thin wrapper around staggerDelay() for parity with the Vue adapter; the computation is cheap enough (plain arithmetic) to just call directly every render, no useMemo needed.

ts
function useStagger(index: number, options?: StaggerDelayOptions): string
tsx
import { useStagger } from '@macrulez/inview-react'

function Item({ index }: { index: number }) {
  const delay = useStagger(index, { step: 70, max: 4 })
  return <div className="reveal" style={{ transitionDelay: delay }} />
}

Scroll To (useScrollTo) ​

useScrollTo() — returns a scrollTo(el, options?) callback that smooth-scrolls target (default window) so el lands at its edge, offset by a px amount — 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).

ts
function useScrollTo(
  target?: Window | HTMLElement | null,
): (el: Element | null | undefined, options?: UseScrollToOptions) => void

target is captured once, in a useCallback dependency array — pass a new target to get a callback bound to that container instead. The returned callback is a no-op when el/target is null.

tsx
import { useScrollTo } from '@macrulez/inview-react'

function JumpButton({ sectionEl }: { sectionEl: HTMLElement | null }) {
  const scrollTo = useScrollTo()
  return <button onClick={() => scrollTo(sectionEl, { offset: 80 })}>Jump</button>
}

Scroll Spy (useScrollSpy) ​

useScrollSpy() — the React equivalent of Scroll Spy's Vue composable: tracks which of targets currently sits in the active band and returns its index (or null).

ts
function useScrollSpy(
  targets: Array<HTMLElement | null | undefined>,
  options?: UseScrollSpyOptions,
): number | null

targets must be a stable array (e.g. from useState, not a fresh [...] literal every render) — a new array reference forces a real re-subscribe, same as target/root elsewhere in this package. options are captured once, when targets itself changes — the same one-shot model useRevealController above has.

tsx
import { useState } from 'react'
import { useScrollSpy } from '@macrulez/inview-react'

function Nav() {
  const [sections] = useState<(HTMLElement | null)[]>(() => [])
  const activeIndex = useScrollSpy(sections)
  return (
    <nav>
      {items.map((item, i) => (
        <a key={item.id} className={i === activeIndex ? 'active' : undefined}>
          {item.title}
        </a>
      ))}
    </nav>
  )
}

Every other @macrulez/inview-core export — createScrollEngine, createVisibilityEngine, createElementTracker, createRevealController, createScrollSpy, ObserverPool/observerPool, rafLoop, staggerDelay, mapRange, bindCSSVar, prefersReducedMotion, clamp, easings, scrollToElement — is available from @macrulez/inview-react the same way, no separate @macrulez/inview-core dependency needed.