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.
Dataset-Driven Parallax
Live preview
The real component, running in this page.
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.jsonPrompt
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.
Source
3 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Section content. Any descendant with the depth attribute becomes a layer. |
| attribute | string | = 'data-parallax' | Attribute carrying the depth. Its value is parsed as a float. |
| fallback | number | = 0.15 | Depth used when the attribute is missing, empty or not a number. |
| scrub | boolean | number | = 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. |
How it works
The technique in order, not the code in order.
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.Each layer gets
gsap.to(layer, { yPercent: -(100 * depth), ease: "none" }).yPercentis a percentage of the element's own height, so a 2000px hero and a 400px mobile hero travel proportionally with no media query.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.The trigger window is
start: "top bottom"toend: "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.invalidateOnRefresh: truemakes ScrollTrigger recompute start and end on resize; percentage-based travel is derived from element height and a stale measurement leaves every layer offset.Under reduced motion the effect returns before creating anything, so the layers render at their authored positions.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| 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. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
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.
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.
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
yin pixels instead ofyPercent. 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.15fallback.el.dataset.parallax || "0.15"covers the missing attribute, butparseFloat("fast")returnsNaNandNaNreachingyPercentdoes 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-autocontainer. 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-parallaxonto 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.