Reverse Parallax

The image moves in the opposite direction of the scroll. Counter-intuitive and memorable. Use sparingly.

Preview

Source

tsx
"use client";

import { useEffect, useRef } from "react";

export interface ParallaxReverseProps {
  imageSrc?: string;
  /** How fast the image moves AGAINST the scroll. 0 = pinned, 1 = matches scroll speed in opposite direction. */
  speed?: number;
  eyebrow?: string;
  headline?: string;
  subhead?: string;
}

export default function ParallaxReverse({
  imageSrc = "/heroes/crouchingtiger/bg.webp",
  speed = 0.45,
  eyebrow = "Parallax · 11",
  headline = "Reverse parallax",
  subhead =
    "The image moves in the OPPOSITE direction of the scroll. Scroll down → image moves down. Counter-intuitive, unsettling, memorable.",
}: ParallaxReverseProps) {
  const sectionRef = useRef<HTMLElement | null>(null);
  const imgRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    let raf = 0;
    let visible = false;
    const io = new IntersectionObserver(
      (entries) => {
        visible = entries[0]?.isIntersecting ?? false;
      },
      { threshold: 0 }
    );
    if (sectionRef.current) io.observe(sectionRef.current);

    const onScroll = () => {
      if (!visible || !sectionRef.current || !imgRef.current) return;
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => {
        const top = sectionRef.current!.getBoundingClientRect().top;
        // POSITIVE offset = move DOWN when section scrolls UP. Reverse of translate-y.
        const offset = top * speed;
        imgRef.current!.style.transform = `translate3d(0, ${offset}px, 0)`;
      });
    };

    onScroll();
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      io.disconnect();
      window.removeEventListener("scroll", onScroll);
      cancelAnimationFrame(raf);
    };
  }, [speed]);

  return (
    <section
      ref={sectionRef}
      className="relative isolate flex min-h-[120svh] w-full items-center justify-center overflow-hidden bg-slate-900"
    >
      <div
        ref={imgRef}
        className="absolute inset-x-0 -top-[30%] -z-10 h-[160%] will-change-transform"
        style={{
          backgroundImage: `url(${imageSrc})`,
          backgroundSize: "cover",
          backgroundPosition: "center",
        }}
      />
      <div className="absolute inset-0 -z-0 bg-gradient-to-b from-black/40 via-black/20 to-black/70" />
      <div className="relative z-10 mx-auto max-w-3xl px-6 text-center text-white">
        <p className="mb-4 text-xs font-semibold uppercase tracking-[0.3em] text-white/70">
          {eyebrow}
        </p>
        <h1 className="text-balance text-4xl font-semibold leading-[1.1] tracking-tight sm:text-6xl">
          {headline}
        </h1>
        <p className="mx-auto mt-6 max-w-xl text-base leading-relaxed text-white/80 sm:text-lg">
          {subhead}
        </p>
      </div>
    </section>
  );
}
Claude Code Instructions

CLI Install

npx innovations add reverse

Where to use it

Same engine as translate-y, but the offset sign is flipped — when the user scrolls DOWN, the image moves DOWN. Brain expects the opposite, which is exactly why it's memorable. WHEN TO USE (sparingly): - Splash / cover sections where unease is the point - Art-directed editorial - Section transitions to signal 'something different is happening' WHEN NOT TO USE: - Anywhere users scroll for a long time — they'll get queasy - Information-dense pages - Anything where flow matters more than impression TUNING: speed 0.45 is the comfortable maximum; above 0.6 it actively fights the user. Start at 0.3 for subtlety.