Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Axis the scroll pass translates layers along. The pointer nudge always works on both axes. */
export type ParallaxAxis = "y" | "x" | "both"

const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
/** A finger has no hover position to read, so the pointer nudge is a fine-pointer-only extra. */
const FINE_POINTER = "(hover: hover) and (pointer: fine)"

/** Per-frame approach rate of the eased pointer offset, and the distance at which it snaps home. */
const POINTER_EASE = 0.16

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/parallax-layers.json

Prompt

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-hidden cards, an overflow-x-hidden page 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.

On This Page