useScrollTrigger

July 29, 2026 · View on GitHub

A high-performance, transparent scroll progress tracker for React.

GSAP ScrollTrigger is powerful but it's a black box — you hand it an element and hope for the best. useScrollTrigger gives you the same scroll-triggered progress tracking with full visibility into how it works, what it reads, and when it fires.

Philosophy

Every design decision serves performance and transparency:

  • No per-frame DOM reads. Element positions are computed once (via useRect) and cached. GSAP calls getBoundingClientRect() on every scroll frame for every trigger — that forces layout recalculation and kills performance on pages with dozens of scroll-driven elements.
  • No React re-renders. Progress updates flow through useLazyState and refs, never through setState. Your scroll callbacks fire at 60fps without touching the React render cycle.
  • No magic. You get progress, direction, isActive, and steps — raw values you wire up yourself. No hidden timeline binding, no implicit CSS changes, no side effects you didn't ask for.
  • Composable. Built on useRect, useLazyState, useEffectEvent, and useTransform — hooks you can use independently. If useScrollTrigger doesn't do what you want, you have the building blocks to make your own.

TransformProvider: the trade-off

Because positions are cached (not read per-frame), programmatic transforms on a parent element (like parallax translateY) would make the cached position stale. TransformProvider solves this — you tell it what you moved, and child scroll triggers compensate automatically.

This is an explicit trade-off: one extra component wrapper in exchange for zero layout thrashing. On a scrollytelling page with 20+ triggers, this is the difference between smooth and janky.

Usage

import { useScrollTrigger } from 'hamo'

function FadeIn() {
  const elementRef = useRef(null)

  const [setRef] = useScrollTrigger({
    onProgress: ({ progress }) => {
      elementRef.current.style.opacity = `${progress}`
    },
  })

  return (
    <div ref={(node) => { elementRef.current = node; setRef(node) }}>
      Fades in as you scroll
    </div>
  )
}

Parameters

Options

  • start: (string, default: 'bottom bottom') Start position: "element-position viewport-position".
  • end: (string, default: 'top top') End position: "element-position viewport-position".
  • offset: (number, default: 0) Pixel offset added to element positions.
  • disabled: (boolean, default: false) Disables the scroll trigger.
  • onEnter: (function) Called when entering the trigger zone. Receives { progress, direction }.
  • onLeave: (function) Called when leaving the trigger zone. Receives { progress, direction }.
  • onProgress: (function) Called on every scroll update. Receives { height, isActive, progress, lastProgress, direction, steps }.
  • steps: (number, default: 1) Subdivides progress into N discrete sub-ranges.
  • debug: (boolean | string, default: false) Registers the trigger in the debug store. Pass a string to use as label in the Debugger minimap.
  • rect: (Rect) External rect from useRect — pass this to share a single useRect across multiple triggers on the same element.

Dependencies

  • deps: (array, default: []) Dependencies that trigger recalculation.

Return Value

Returns [setRef, rect]:

  1. setRef — Callback ref to attach to the target element.
  2. rect — The element's current rect (from useRect internally).

Position Syntax

Format: "element-position viewport-position"

KeywordElementViewport
topTop edgeTop of viewport
centerVertical centerCenter of viewport
bottomBottom edgeBottom of viewport
numberPixel offset from topPixel offset from top

Common Combinations

Start / EndDescription
'bottom bottom' / 'top top'Full element traversal (default)
'bottom bottom' / 'top center'From entering viewport to reaching center
'center center' / 'top top'From center alignment to reaching top

onProgress Callback Data

PropertyTypeDescription
progressnumberClamped progress from 0 to 1
lastProgressnumberPrevious progress value
direction1 | -1Scroll direction (1 = down, -1 = up)
isActivebooleanWhether progress is between 0 and 1
heightnumberScroll distance in pixels between start and end
stepsnumber[]Array of per-step progress values

How It Works

useScrollTrigger
  ├── useRect()         → computes element position once, caches it
  ├── useWindowSize()   → viewport height for position keywords
  ├── useTransform()    → reads parent transform offset (if any)
  ├── useLenis()        → subscribes to Lenis scroll (or falls back to native)
  ├── useLazyState()    → tracks progress without re-renders
  └── useEffectEvent()  → stable callbacks, no effect churn

On every scroll tick:
  1. Read scroll position (from Lenis or window.scrollY)
  2. Subtract parent transform offset (from TransformProvider)
  3. Map scroll into [0, 1] progress using cached element position
  4. Fire onEnter/onLeave/onProgress if progress changed
  5. Zero DOM reads. Zero re-renders.

Examples

Enter / Leave with Direction

import { useScrollTrigger } from 'hamo'

function Section() {
  const [setRef] = useScrollTrigger({
    onEnter: ({ direction }) => {
      console.log(direction === 1 ? 'Entered scrolling down' : 'Entered scrolling up')
    },
    onLeave: ({ direction }) => {
      console.log(direction === 1 ? 'Left scrolling down' : 'Left scrolling up')
    },
  })

  return <div ref={setRef}>Tracked section</div>
}

Steps for Staggered Animations

import { useRef } from 'react'
import { useScrollTrigger } from 'hamo'

function StaggeredList() {
  const itemRefs = useRef([])

  const [setRef] = useScrollTrigger({
    steps: 5,
    onProgress: ({ steps }) => {
      steps.forEach((stepProgress, i) => {
        if (itemRefs.current[i]) {
          itemRefs.current[i].style.opacity = `${stepProgress}`
          itemRefs.current[i].style.transform = `translateY(${(1 - stepProgress) * 20}px)`
        }
      })
    },
  })

  return (
    <ul ref={setRef}>
      {Array.from({ length: 5 }).map((_, i) => (
        <li key={i} ref={(el) => { itemRefs.current[i] = el }}>
          Item {i + 1}
        </li>
      ))}
    </ul>
  )
}

With Lenis

When Lenis is installed, the hook automatically uses it. No configuration needed.

import { ReactLenis } from 'lenis/react'
import { useScrollTrigger } from 'hamo'

function App() {
  return (
    <ReactLenis root>
      <AnimatedSection />
    </ReactLenis>
  )
}

function AnimatedSection() {
  const elementRef = useRef(null)

  const [setRef] = useScrollTrigger({
    onProgress: ({ progress }) => {
      elementRef.current.style.transform = `translateX(${progress * 100}px)`
    },
  })

  return (
    <div ref={(node) => { elementRef.current = node; setRef(node) }}>
      Slides in with smooth scroll
    </div>
  )
}

CSS Custom Property

import { useScrollTrigger } from 'hamo'

function ParallaxSection() {
  const elementRef = useRef(null)

  const [setRef] = useScrollTrigger({
    onProgress: ({ progress }) => {
      elementRef.current.style.setProperty('--progress', `${progress}`)
    },
  })

  return (
    <div
      ref={(node) => { elementRef.current = node; setRef(node) }}
      style={{ transform: 'translateY(calc(var(--progress, 0) * -50px))' }}
    >
      Parallax content
    </div>
  )
}

With TransformProvider (Parallax Compensation)

When a parent is translated programmatically, child scroll triggers need to know. Wrap the parent in TransformProvider and report your transforms — children compensate automatically.

import { useRef } from 'react'
import { useScrollTrigger, TransformProvider } from 'hamo'
import type { TransformRef } from 'hamo'

function ParallaxWrapper({ children }) {
  const transformRef = useRef<TransformRef>(null)
  const elementRef = useRef(null)

  const [setRef] = useScrollTrigger({
    onProgress: ({ progress }) => {
      const y = (progress - 0.5) * -100
      transformRef.current?.setTranslate(0, y)
      elementRef.current.style.transform = `translateY(${y}px)`
    },
  })

  return (
    <TransformProvider ref={transformRef}>
      <div ref={(node) => { elementRef.current = node; setRef(node) }}>
        {children}
      </div>
    </TransformProvider>
  )
}

function ChildTrigger() {
  const [setRef] = useScrollTrigger({
    onProgress: ({ progress }) => {
      // Accurate despite parent parallax — no getBoundingClientRect needed
      console.log('progress:', progress)
    },
  })

  return <div ref={setRef}>Child content</div>
}

function Page() {
  return (
    <ParallaxWrapper>
      <ChildTrigger />
    </ParallaxWrapper>
  )
}

Progressive Text Reveal

import { useRef } from 'react'
import { useScrollTrigger } from 'hamo'

function TextReveal({ text }) {
  const containerRef = useRef(null)
  const words = text.split(' ')

  const [setRef] = useScrollTrigger({
    start: 'bottom bottom',
    end: 'center center',
    steps: words.length,
    onProgress: ({ steps }) => {
      if (!containerRef.current) return
      const spans = containerRef.current.querySelectorAll('span')
      spans.forEach((span, i) => {
        span.style.opacity = `${steps[i]}`
      })
    },
  })

  return (
    <p ref={(node) => { containerRef.current = node; setRef(node) }}>
      {words.map((word, i) => (
        <span key={i} style={{ opacity: 0 }}>
          {word}{' '}
        </span>
      ))}
    </p>
  )
}

Debugger

A minimap overlay that visualizes all active scroll triggers on the page. Each trigger with debug enabled appears as a colored rectangle (element position) and a bar (start/end range). Hover a rectangle to see a tooltip with the trigger's id, start/end positions, progress, and active state.

import { Debugger } from 'hamo/scroll-trigger'

function App() {
  return (
    <>
      <Debugger theme="light" />
      {/* your content */}
    </>
  )
}

Props

  • theme: ('light' | 'dark', default: 'dark') Color theme.

How it works

Triggers with debug enabled register themselves in a shared store. The Debugger subscribes to that store and renders a fixed minimap that:

  • Mirrors the page body shape using the body's aspect ratio
  • Scrolls in sync via a CSS custom property (--p)
  • Shows each trigger's element as a colored rectangle, offset by translateY from TransformProvider
  • Shows each trigger's start/end scroll range as a colored bar aligned to its element
  • Displays a tooltip on hover with trigger details (id, start, end, progress, active)

Enabling debug on a trigger

const [setRef] = useScrollTrigger({
  start: 'bottom bottom',
  end: 'top top',
  debug: 'my-section', // label shown in tooltip
  onProgress: ({ progress }) => { /* ... */ },
})

vs GSAP ScrollTrigger

GSAP ScrollTriggeruseScrollTrigger
DOM reads per framegetBoundingClientRect() on every tickZero — positions cached via useRect
React re-rendersN/A (not React-aware)Zero — updates via refs and useLazyState
Lenis integrationManual scrollerProxy setupAutomatic
Transform awarenessImplicit (reads DOM)Explicit via TransformProvider
Bundle size~55KB (GSAP core + plugin)~1KB (inlined in hamo)
Pinning, scrub, snapYesNo — use GSAP for those
LicenseCustom (restrictions on some commercial use)MIT
TransparencyBlack boxEvery hook is composable and inspectable