Parallax Layers
A depth wrapper — each child declares a depth and translates at that rate as the container passes the viewport, with an optional pointer nudge, one rAF and no re-renders.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/parallax-layers.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ParallaxLayers" component — a depth
wrapper whose children translate at a rate proportional to a depth they
declare, driven by the container's pass through the scrollport.
Contract
- Export two forwardRef components sharing one context.
ParallaxLayers extends React.HTMLAttributes<HTMLDivElement>:
range (px a layer at depth 1 travels across one full pass, default 80),
axis = "y" | "x" | "both" (which axis the scroll pass translates along,
default "y"), clamp (hard cap on |translation| per axis in px after depth
is applied, Infinity disables it, default 160), pointer (boolean, default
true — adds a small pointer-driven nudge), pointerRange (px at depth 1
between the container centre and its edge, default 14), disabled
(boolean — park everything at rest).
ParallaxLayer extends React.HTMLAttributes<HTMLDivElement> with one prop:
depth (number, default 0). Positive = further away (lags the page),
0 = pinned to the page, negative = closer (leads it). The same sign rule
applies to the pointer nudge: positive follows the cursor, negative
counter-moves, so the two drivers never contradict each other.
- ParallaxLayer throws an explanatory error when rendered outside the
container — the container owns the loop, a lone layer cannot move.
Behavior
- ONE measurement pass per animation frame drives every layer. Scroll,
pointer, resize and the pointer easing all call the same schedule(),
which no-ops when a frame is already queued, so a burst of events costs
one measurement, not one per event.
- Progress: measure the container's rect against the scrollport and map it
to -1 (still below the fold) → 0 (centre meets centre) → +1 (gone past
the top), clamped. span = (scrollportHeight + containerHeight) / 2.
Publish it as a --parallax-progress custom property on the root so
consumers can drive their own CSS from the same number.
- Scrollport: the nearest ancestor the USER can scroll (overflow auto or
scroll AND scrollHeight - clientHeight > 1), else the viewport. `hidden`
and `clip` are always skipped however much content overflows them — an
overflow-hidden card, or a `<main class="overflow-x-hidden">` page shell
with a bleeding decoration, never scrolls, it travels with the page, so
treating it as the scrollport would freeze progress at a constant. Never
return documentElement/body (their rects move with the page scroll).
- Per layer: offset = (scrollShift on the enabled axes + easedPointer *
pointerRange) * depth, clamped to ±clamp, rounded to 2 decimals, written
as `transform: translate3d(x, y, 0)` straight onto the node. Cache the
last string per node and skip identical writes; set will-change:
transform only while the value is non-empty. The container itself never
re-renders while scrolling.
- Pointer: listeners live on the root, passive, and only store clientX/
clientY — all geometry is read once per frame inside the paint pass.
The applied offset eases toward the target (~0.16 per frame) and eases
home on pointerleave, so nothing snaps; the loop re-schedules itself
only while that easing is in flight, then stops.
- Focus mirrors hover: focusin inside the container moves the parallax
origin to the centre of whatever took focus, focusout to the outside
releases it — a keyboard user gets the same effect a mouse user does.
- Registration: layers register themselves in a Map<node, {depth, last}>
through context. Changing depth updates the record in place (never
unregister + re-register, which would flash a frame); unmounting removes
the record and clears the transform it wrote.
- Cleanup: cancelAnimationFrame plus removal of every listener,
ResizeObserver and IntersectionObserver on unmount, and again whenever
the component switches to its rest state.
- Cheap when off-screen: an IntersectionObserver flips a flag that makes
schedule() bail entirely, and paints once on every crossing so the
clamped ends stay exact.
Rendering & styling
- Semantic tokens only — the wrapper paints nothing, it only moves what
the children already look like. The root is `relative isolate
overflow-hidden` merged through cn() so consumers restyle it freely.
- prefers-reduced-motion is read with matchMedia through
useSyncExternalStore (server snapshot false, live subscription so a
mid-session change is honoured) and puts every layer AT REST: the
transform is actively cleared, not merely left un-updated. Nothing is
hidden, nothing waits on an animation, the section reads complete.
`disabled` produces the exact same output.
- Coarse pointers: the nudge requires
"(hover: hover) and (pointer: fine)"; on touch it is dropped while the
scroll pass keeps working. Listeners are passive and nothing is ever
prevented, so touch scrolling is untouched.
- SSR: no window/document access during render; the first client frame
matches the server (no motion, no nudge) and the machinery starts in an
effect.
- Accessibility: decorative planes get aria-hidden + pointer-events-none
from the consumer; interactive content inside a layer stays keyboard
reachable with its own focus-visible ring, and layer transforms never
affect hit testing beyond their own translation.
Customization levers
- Depth spread: 0.8-1.4 for far backdrops, 0.3-0.6 for mid planes, 0 for
anything that must stay glued to the page, -0.2 to -0.9 for foreground
elements. A hero reads best with 3-5 planes; more than that stops being
legible as depth.
- Intensity: range 40-60 is a whisper, 80-120 is a marketing hero,
160+ needs a clamp so the deepest plane cannot leave the frame.
- Axis: "y" for the classic section parallax, "x" for editorial sideways
drift, "both" for a diagonal float. The pointer nudge is independent of
this and always works on both axes.
- Pointer: pointer={false} for dense pages or when layers carry text;
pointerRange 8-20 — beyond ~24px it starts to read as a wobble.
- Colour: decorative planes are yours — radial glows via
color-mix(in oklab, var(--chart-1..5) …), grids from var(--border),
content planes on bg-card / text-muted-foreground.
- Progress hook: read var(--parallax-progress, 0) in your own CSS for a
scrubbed bar, an opacity ramp or a scale, with no extra listener.Concepts
- Depth is a rate, not a position — a layer never gets coordinates; it declares how fast it should move relative to the page, and the container multiplies that by one shared progress number. Adding a plane is one prop, not a new listener.
- One pass, many layers — scroll, resize, pointer and the easing loop all funnel into a single
requestAnimationFrame; every layer is positioned from the same measurement, so ten planes cost exactly what one costs. - Real scrollport, not "the window" — progress is measured against the nearest ancestor the user can actually scroll; boxes that only clip (
overflow-hiddencards, anoverflow-x-hiddenpage shell) are skipped, because they travel with the page and would otherwise freeze the whole effect. Inside a panel it behaves exactly as it does on a page. - Eased pointer, instant scroll — the scroll term is written raw so layers stay glued to the scroll position, while the pointer term eases in and out; the loop only keeps itself alive while that easing is still travelling.
- Focus is the keyboard's cursor — whatever inside takes focus becomes the parallax origin, so the hover effect is not a mouse-only reward, and tabbing through a hero shows the same depth shift.
- Rest is a written state — under reduced motion,
disabled, or before hydration, transforms are actively cleared and content sits exactly where the markup puts it; there is no state in which a layer is hidden waiting for an animation that never runs.
Split Screen Hover
Full-height panels that share one row and rebalance around the pointer — the hovered or focused panel takes the width and opens its copy, the rest compress and dim.
Scroll Velocity Skew
A wrapper that leans and slightly scales its content in proportion to scroll velocity, then eases back to neutral the moment scrolling stops.