Skip to content
Kinetic
ScrollDOM3 files

Dataset-Driven Parallax

Depth is declared in the markup: any descendant carrying `data-parallax="0.3"` drifts at that rate. One effect walks the container, parses the attribute and builds a scrubbed tween per layer, so adding a new plane to a hero is an attribute rather than a code change. Travel is expressed as a percentage of the element's own height, which keeps the effect proportional at every breakpoint.

gsapscrolltriggerparallaxscrubdata-attribute

Live preview

The real component, running in this page.

02

Install

Copies the source into your project. Nothing is imported from us at runtime.

$npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/dataset-parallax.json
pulls ingsap@^3.15.0
03

Prompt

The whole technique, written for an agent.

Build it with your agent

A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.

Claude CodeCursorCodexCopilotGemini CLI
.md
# Build: Dataset-Driven Parallax

Depth is declared in the markup: any descendant carrying `data-parallax="0.3"` drifts at that rate. One effect walks the container, parses the attribute and builds a scrubbed tween per layer, so adding a new plane to a hero is an attribute rather than a code change. Travel is expressed as a percentage of the element's own height, which keeps the effect proportional at every breakpoint.

Build this as a React component in my project. Match my existing conventions —
file layout, styling system, and naming — rather than the ones implied below.
Everything after this line is specification, not code to copy verbatim.

## What it does

Layers drift at a rate written on the element itself, over the widest possible scroll window.

- Category: scroll
- Runtime cost: dom
- Dependencies: gsap@^3.15.0

## How it works

1. The container is walked once for `[data-parallax]` descendants. The depth is read with a double fallback — `parseFloat(el.getAttribute(attr) ?? "") || fallback` — so a missing value, an empty value and a non-numeric value all land on the same default.
2. Each layer gets `gsap.to(layer, { yPercent: -(100 * depth), ease: "none" })`. `yPercent` is a percentage of the element's own height, so a 2000px hero and a 400px mobile hero travel proportionally with no media query.
3. `ease: "none"` is required, not stylistic. Any easing on a scrubbed tween means the layer accelerates and decelerates independently of the wheel, so two layers at different depths drift out of sync mid-scroll and snap back at the ends.
4. The trigger window is `start: "top bottom"` to `end: "bottom top"` — the full travel range, from the instant the element enters the scroller to the instant it leaves. That is the widest window available, which spreads the same travel across the most scroll distance.
5. `invalidateOnRefresh: true` makes ScrollTrigger recompute start and end on resize; percentage-based travel is derived from element height and a stale measurement leaves every layer offset.
6. Under reduced motion the effect returns before creating anything, so the layers render at their authored positions.

## Constants — use these values

These are tuned. Each one has a reason; do not round them off or replace them
with values that "look cleaner".

- `DEFAULT_DEPTH = 0.15`
  Fallback drift: 15% of the element's height across the whole window. Below ~0.05 the layer looks static next to its neighbours; above ~0.4 it visibly separates from the content it belongs to and reads as a rendering fault.
- `start / end = 'top bottom' / 'bottom top'`
  The maximum-length window. Narrowing it (say `top 80%` to `bottom 20%`) forces the same travel through less scroll, which is the difference between drift and the page appearing to come apart.
- `yPercent multiplier = -100`
  Negative so layers move against the scroll — the direction that reads as distance. A positive multiplier moves them with the scroll, which reads as the layer being dragged.
- `scrub = true`
  Locked to scroll position with no lag. Parallax is a spatial relationship: a numeric scrub adds lag, and lagging layers cross each other during a fast flick.

## API

```ts
function DatasetParallax(props: { children: ReactNode; attribute?: string; fallback?: number; scrub?: boolean | number; scrollerRef?: RefObject<HTMLElement | null>; className?: string }): JSX.Element
```

- `children: ReactNode`
  Section content. Any descendant with the depth attribute becomes a layer.
- `attribute: string` — default `'data-parallax'`
  Attribute carrying the depth. Its value is parsed as a float.
- `fallback: number` — default `0.15`
  Depth used when the attribute is missing, empty or not a number.
- `scrub: boolean | number` — default `true`
  ScrollTrigger scrub. A number adds that many seconds of lag.
- `scrollerRef: RefObject<HTMLElement | null>`
  The scroll container that owns the scroll position. Omit for the document.
- `className: string`
  Merged onto the container via `cn`.

## Reference implementation of the core

This is the part that is hard to get right. Adapt it; do not treat the rest of
the component as fixed.

```tsx
const DEFAULT_DEPTH = 0.15;

export function DatasetParallax({
  children,
  attribute = 'data-parallax',
  fallback = DEFAULT_DEPTH,
  scrub = true,
  scrollerRef,
  className,
}: DatasetParallaxProps) {
  const root = useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();

  useGsap(
    gsap,
    () => {
      const el = root.current;
      if (!el || reduced) return;

      const scroller = scrollerRef?.current ?? undefined;

      for (const layer of Array.from(el.querySelectorAll<HTMLElement>(`[${attribute}]`))) {
        // Double fallback, and both halves are load-bearing. `?? ''` handles a
        // bare `data-parallax` with no value; `|| fallback` handles
        // `parseFloat` returning NaN on anything non-numeric. A NaN reaching
        // `yPercent` does not throw — it silently pins the layer at its
        // untransformed position, which looks like the effect simply not
        // working on that one element.
        const raw = layer.getAttribute(attribute) ?? '';
        const depth = parseFloat(raw) || fallback;

        gsap.to(layer, {
          yPercent: -(100 * depth),
          ease: 'none',
          scrollTrigger: {
            trigger: layer,
            scroller,
            // The widest possible window: begins the instant the layer's top
            // touches the bottom of the scroller and ends when its bottom
            // leaves the top. Any narrower range concentrates the same travel
            // into less scroll, which is what makes cheap parallax feel like
            // the page is sliding apart.
            start: 'top bottom',
            end: 'bottom top',
            scrub,
            // Percent-based travel is recomputed from element height; without
            // this a resize leaves every layer measured against the old one.
            invalidateOnRefresh: true,
          },
        });
      }
    },
    root,
    [attribute, fallback, scrub, reduced],
  );
```

## Requirements

These are not optional. A version of this component that skips any of them is
not finished.

**Cleanup.** `useGsap` wraps the whole walk in a `gsap.context`; `ctx.revert()` on unmount kills every tween and its ScrollTrigger and strips the inline `transform` GSAP wrote. Without the revert the triggers survive the unmount and keep scrubbing detached nodes on the next page.

**Reduced motion.** The effect returns before creating any tween, so every layer stays exactly where the stylesheet put it. The layout is authored to be correct at rest, so nothing is missing — the planes simply do not separate.

**Frame-rate independence.** Any smoothing must be a function of delta time.
`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use
`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where
`smoothing` is the fraction of the gap remaining after one second.

**Strict mode.** Assume effects run twice in development. Setup must be
idempotent and teardown must fully undo it.

## Known pitfalls — avoid these specifically

- Using `y` in pixels instead of `yPercent`. It looks correct at the breakpoint you tuned and wrong at every other one — on a short viewport a fixed 300px offset can push the layer entirely out of its own container.
- A single `|| 0.15` fallback. `el.dataset.parallax || "0.15"` covers the missing attribute, but `parseFloat("fast")` returns `NaN` and `NaN` reaching `yPercent` does not throw — the layer just silently stops moving, which is a bug you find by eye weeks later.
- Adding an ease to a scrubbed tween. The layer then accelerates on its own schedule rather than the wheel's, so layers at different depths drift out of relative order and snap at each end of the range.
- Omitting `invalidateOnRefresh`. Rotating a phone or opening a sidebar changes element height; the tween keeps using the height measured on mount and every layer sits at the wrong offset until reload.
- Leaving the scroller as the document when the content lives in an `overflow-y-auto` container. Every start and end is then measured against the window, so nothing moves at the right time — or at all.
- Building one giant timeline for all layers. Independent triggers per layer are correct here: a layer must respond to its own position in the scroller, not to a shared progress value.

## Notes

- The attribute name is configurable so the component can coexist with an existing convention rather than forcing `data-parallax` onto a codebase that already has one.
- Depths above 1 are legal and useful for a single foreground element that should outrun everything, but two such layers next to each other stop reading as depth.

## Definition of done

Scroll the section: layers with a larger `data-parallax` value visibly outrun the ones with smaller values, and every layer is back in its authored position when it leaves the scroller. Change an attribute value in devtools and reload — the rate changes with no code edit. Resize the window mid-scroll and the layers stay aligned with their content.

When you are finished, tell me which of the requirements above you implemented
and where, so I can check them.
159 lines · 1441 words
04

Source

3 files, exactly as installed.

installs tocomponents/motion/dataset-parallax.tsx
'use client';

import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useRef, type ReactNode, type RefObject } from 'react';
import { cn } from '@/lib/utils';
import { useGsap } from '@/hooks/use-gsap';
import { useReducedMotion } from '@/hooks/use-reduced-motion';

/* ==========================================================================
   Dataset-Driven Parallax
   --------------------------------------------------------------------------
   The parallax rate lives in the markup, not in the JavaScript. One effect
   walks the container for `[data-parallax]`, reads the depth off the
   attribute, and builds a scrub tween per element. Adding a new layer is an
   attribute; there is no registry of selectors to keep in sync and no
   component that has to be told about every element it will ever move.

   The whole trick is `yPercent: -(100 * depth)`. Percent-of-own-height rather
   than pixels means an element that is 2000px tall on desktop and 400px tall
   on mobile travels a proportional distance at both sizes, with no media
   query and no re-measurement.
   ========================================================================== */

if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);

export interface DatasetParallaxProps {
  children: ReactNode;
  /** Attribute carrying the depth. Its value is parsed as a float. */
  attribute?: string;
  /** Depth used when the attribute is missing, empty, or not a number. */
  fallback?: number;
  /** ScrollTrigger `scrub`. `true` is locked to the wheel; a number adds lag in seconds. */
  scrub?: boolean | number;
  /** The scroll container that owns the scroll position. Omit for the document. */
  scrollerRef?: RefObject<HTMLElement | null>;
  className?: string;
}

// #region critical
const DEFAULT_DEPTH = 0.15;

export function DatasetParallax({
  children,
  attribute = 'data-parallax',
  fallback = DEFAULT_DEPTH,
  scrub = true,
  scrollerRef,
  className,
}: DatasetParallaxProps) {
  const root = useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();

  useGsap(
    gsap,
    () => {
      const el = root.current;
      if (!el || reduced) return;

      const scroller = scrollerRef?.current ?? undefined;

      for (const layer of Array.from(el.querySelectorAll<HTMLElement>(`[${attribute}]`))) {
        // Double fallback, and both halves are load-bearing. `?? ''` handles a
        // bare `data-parallax` with no value; `|| fallback` handles
        // `parseFloat` returning NaN on anything non-numeric. A NaN reaching
        // `yPercent` does not throw — it silently pins the layer at its
        // untransformed position, which looks like the effect simply not
        // working on that one element.
        const raw = layer.getAttribute(attribute) ?? '';
        const depth = parseFloat(raw) || fallback;

        gsap.to(layer, {
          yPercent: -(100 * depth),
          ease: 'none',
          scrollTrigger: {
            trigger: layer,
            scroller,
            // The widest possible window: begins the instant the layer's top
            // touches the bottom of the scroller and ends when its bottom
            // leaves the top. Any narrower range concentrates the same travel
            // into less scroll, which is what makes cheap parallax feel like
            // the page is sliding apart.
            start: 'top bottom',
            end: 'bottom top',
            scrub,
            // Percent-based travel is recomputed from element height; without
            // this a resize leaves every layer measured against the old one.
            invalidateOnRefresh: true,
          },
        });
      }
    },
    root,
    [attribute, fallback, scrub, reduced],
  );
  // #endregion critical

  return (
    <div ref={root} className={cn(className)}>
      {children}
    </div>
  );
}
05

API

Every parameter, its type and what happens when you change it.

function DatasetParallax(props: { children: ReactNode; attribute?: string; fallback?: number; scrub?: boolean | number; scrollerRef?: RefObject<HTMLElement | null>; className?: string }): JSX.Element
Parameters
childrenReactNodeSection content. Any descendant with the depth attribute becomes a layer.
attributestring = 'data-parallax'Attribute carrying the depth. Its value is parsed as a float.
fallbacknumber = 0.15Depth used when the attribute is missing, empty or not a number.
scrubboolean | number = trueScrollTrigger scrub. A number adds that many seconds of lag.
scrollerRefRefObject<HTMLElement | null>The scroll container that owns the scroll position. Omit for the document.
classNamestringMerged onto the container via cn.
06

How it works

The technique in order, not the code in order.

  1. 01

    The container is walked once for [data-parallax] descendants. The depth is read with a double fallback — parseFloat(el.getAttribute(attr) ?? "") || fallback — so a missing value, an empty value and a non-numeric value all land on the same default.

  2. 02

    Each layer gets gsap.to(layer, { yPercent: -(100 * depth), ease: "none" }). yPercent is a percentage of the element's own height, so a 2000px hero and a 400px mobile hero travel proportionally with no media query.

  3. 03

    ease: "none" is required, not stylistic. Any easing on a scrubbed tween means the layer accelerates and decelerates independently of the wheel, so two layers at different depths drift out of sync mid-scroll and snap back at the ends.

  4. 04

    The trigger window is start: "top bottom" to end: "bottom top" — the full travel range, from the instant the element enters the scroller to the instant it leaves. That is the widest window available, which spreads the same travel across the most scroll distance.

  5. 05

    invalidateOnRefresh: true makes ScrollTrigger recompute start and end on resize; percentage-based travel is derived from element height and a stale measurement leaves every layer offset.

  6. 06

    Under reduced motion the effect returns before creating anything, so the layers render at their authored positions.

Load-bearing constants

Load-bearing constants
DEFAULT_DEPTH = 0.15Fallback drift: 15% of the element's height across the whole window. Below ~0.05 the layer looks static next to its neighbours; above ~0.4 it visibly separates from the content it belongs to and reads as a rendering fault.
start / end = 'top bottom' / 'bottom top'The maximum-length window. Narrowing it (say top 80% to bottom 20%) forces the same travel through less scroll, which is the difference between drift and the page appearing to come apart.
yPercent multiplier = -100Negative so layers move against the scroll — the direction that reads as distance. A positive multiplier moves them with the scroll, which reads as the layer being dragged.
scrub = trueLocked to scroll position with no lag. Parallax is a spatial relationship: a numeric scrub adds lag, and lagging layers cross each other during a fast flick.
07

Behaviour

What it does about motion sensitivity, teardown and being finished.

Reduced motion

The effect returns before creating any tween, so every layer stays exactly where the stylesheet put it. The layout is authored to be correct at rest, so nothing is missing — the planes simply do not separate.

Cleanup

useGsap wraps the whole walk in a gsap.context; ctx.revert() on unmount kills every tween and its ScrollTrigger and strips the inline transform GSAP wrote. Without the revert the triggers survive the unmount and keep scrubbing detached nodes on the next page.

Definition of done

Scroll the section: layers with a larger data-parallax value visibly outrun the ones with smaller values, and every layer is back in its authored position when it leaves the scroller. Change an attribute value in devtools and reload — the rate changes with no code edit. Resize the window mid-scroll and the layers stay aligned with their content.

Pitfalls

  • Using y in pixels instead of yPercent. It looks correct at the breakpoint you tuned and wrong at every other one — on a short viewport a fixed 300px offset can push the layer entirely out of its own container.
  • A single || 0.15 fallback. el.dataset.parallax || "0.15" covers the missing attribute, but parseFloat("fast") returns NaN and NaN reaching yPercent does not throw — the layer just silently stops moving, which is a bug you find by eye weeks later.
  • Adding an ease to a scrubbed tween. The layer then accelerates on its own schedule rather than the wheel's, so layers at different depths drift out of relative order and snap at each end of the range.
  • Omitting invalidateOnRefresh. Rotating a phone or opening a sidebar changes element height; the tween keeps using the height measured on mount and every layer sits at the wrong offset until reload.
  • Leaving the scroller as the document when the content lives in an overflow-y-auto container. Every start and end is then measured against the window, so nothing moves at the right time — or at all.
  • Building one giant timeline for all layers. Independent triggers per layer are correct here: a layer must respond to its own position in the scroller, not to a shared progress value.

Notes

  • The attribute name is configurable so the component can coexist with an existing convention rather than forcing data-parallax onto a codebase that already has one.
  • Depths above 1 are legal and useful for a single foreground element that should outrun everything, but two such layers next to each other stop reading as depth.