Skip to content
Motion

Parallax

One rAF loop writing a transform, not a scroll handler. A handler that writes styles on every scroll event forces layout inside the scroll itself, which is the classic way to make a page feel heavy — and scroll fires far more often than the compositor can paint, so most of that work is thrown away. The offset is measured from the viewport CENTRE rather than absolute scrollY, so the effect is neutral when centred and the same speed value looks identical at the top of a page and at the bottom.

Scroll inside the pane below — the page stays where it is.

Loading preview…
Loading preview…
Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Parallax } from "@/components/parallax"; <Parallax />

Customise

-0.2

Negative moves against the scroll, positive with it.

180px

Caps the offset so content cannot drift out of its container.

The component

components/craft/motion/Parallax.tsx

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react"; /** * Moves its children against the scroll, at a rate you choose. * * Driven by one rAF loop writing a transform, not by a scroll handler. A * handler that writes styles on every scroll event forces layout inside the * scroll itself, which is the classic way to make a page feel heavy — and * scroll fires far more often than the compositor can paint, so most of that * work is discarded anyway. * * The offset is derived from the element's position relative to the VIEWPORT * CENTRE rather than from absolute scrollY. That makes the effect independent * of where the element sits in the document: it is neutral when centred and * symmetric either side, so the same speed value looks the same at the top of * a page and at the bottom. */ export interface ParallaxProps {  children: React.ReactNode;  /** Negative moves against the scroll, positive with it. */  speed?: number;  axis?: "y" | "x";  /** Caps the travel so content cannot drift out of its container. */  clamp?: number;  still?: boolean;  className?: string;} export function Parallax({  children,  speed = -0.2,  axis = "y",  clamp = 180,  still = false,  className = "",}: ParallaxProps) {  const hostRef = useRef<HTMLDivElement>(null);  const moverRef = useRef<HTMLDivElement>(null);   useEffect(() => {    const host = hostRef.current;    const mover = moverRef.current;    if (!host || !mover) return;     const reduced =      still ||      (typeof matchMedia === "function" &&        matchMedia("(prefers-reduced-motion: reduce)").matches);    if (reduced) return;     let frame = 0;    let last = Number.NaN;    let visible = true;     // Only runs while the element is on screen. A page with a dozen of these    // would otherwise keep measuring all of them forever.    const observer = new IntersectionObserver(      ([entry]) => {        visible = entry.isIntersecting;      },      { rootMargin: "20% 0px" },    );    observer.observe(host);     const tick = () => {      if (visible) {        const box = host.getBoundingClientRect();        // Distance from the viewport centre: neutral when centred, so the        // same speed reads identically anywhere in the document.        const fromCentre = box.top + box.height / 2 - window.innerHeight / 2;        const raw = fromCentre * speed;        const offset = Math.max(-clamp, Math.min(clamp, raw));         // Only touch the DOM when the value actually changed — sub-pixel        // churn would invalidate the layer every frame for no visible gain.        const rounded = Math.round(offset * 100) / 100;        if (rounded !== last) {          last = rounded;          mover.style.transform =            axis === "y"              ? `translate3d(0, ${rounded}px, 0)`              : `translate3d(${rounded}px, 0, 0)`;        }      }      frame = requestAnimationFrame(tick);    };     frame = requestAnimationFrame(tick);     return () => {      cancelAnimationFrame(frame);      observer.disconnect();      mover.style.transform = "";    };  }, [speed, axis, clamp, still]);   return (    <div ref={hostRef} className={className}>      <div ref={moverRef} className="will-change-transform">        {children}      </div>    </div>  );} 

One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.

What it costs

Weight

+1.3KB gzipped · no new dependencies

Runs on

Compositor

Animates transform and opacity only, so the compositor handles it off the main thread. Scroll and input stay responsive even while it runs.

Reduced motion

No loop and no transform. Children sit exactly where layout put them.

Accessibility
  • Adds no roles and no tab stops — children keep their own semantics.
  • Only transform is written, so text stays selectable and focus rings stay correct.
  • Parallax is a common vestibular trigger, which is why the reduced path removes it rather than softening it.
Watch out
  • Measures a rect per frame while on screen. It pauses via IntersectionObserver when scrolled away, but a page with dozens of these is still dozens of measurements.
  • Travel is clamped, so an extreme speed will visibly stop moving at the limit rather than drifting away.