Skip to content
ARDENT / MOTION LAB
Library

Cinematic Heroes · Intermediate

Layered Depth Hero

Normalised pointer position drives a shared spring; each layer multiplies it by a different depth factor.

Live preview

Move a mouse across the hero to separate its layers.

Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
 * Layered Hero
 * Three spring-smoothed planes create a restrained editorial parallax.
 * Requires: motion, Tailwind with shadcn semantic tokens.
 */
import { useEffect } from "react";
import { motion, useReducedMotion, useSpring, useTransform } from "motion/react";

export default function LayeredHero({
  reduced: reducedProp,
  intensity = 1,
}: {
  reduced?: boolean;
  intensity?: number;
}) {
  const systemReduced = useReducedMotion();
  const reduced = reducedProp ?? systemReduced ?? false;
  const amount = Math.min(1.5, Math.max(0.5, intensity));
  const x = useSpring(0, { stiffness: 65, damping: 22 });
  const y = useSpring(0, { stiffness: 65, damping: 22 });
  const backX = useTransform(x, (value) => value * 12);
  const backY = useTransform(y, (value) => value * 12);
  const titleX = useTransform(x, (value) => value * 26);
  const titleY = useTransform(y, (value) => value * 26);
  const frontX = useTransform(x, (value) => value * 48);
  const frontY = useTransform(y, (value) => value * 48);

  useEffect(() => {
    if (reduced) {
      x.jump(0);
      y.jump(0);
    }
  }, [reduced, x, y]);

  function reset() {
    x.set(0);
    y.set(0);
  }

  return (
    <section
      className="relative isolate flex min-h-[80vh] items-center justify-center overflow-hidden bg-background px-6 py-24 text-foreground"
      onPointerLeave={reset}
      onPointerCancel={reset}
      onPointerMove={(event) => {
        if (
          reduced ||
          event.pointerType !== "mouse" ||
          !window.matchMedia("(hover: hover) and (pointer: fine)").matches
        )
          return;
        const rect = event.currentTarget.getBoundingClientRect();
        x.set(((event.clientX - rect.left) / rect.width - 0.5) * amount);
        y.set(((event.clientY - rect.top) / rect.height - 0.5) * amount);
      }}
    >
      <motion.img
        src="https://picsum.photos/seed/ardent-horizon/1920/1200"
        alt=""
        style={{ x: reduced ? 0 : backX, y: reduced ? 0 : backY }}
        className="pointer-events-none absolute inset-[-5%] -z-20 h-[110%] w-[110%] object-cover opacity-25"
      />
      <div className="absolute inset-0 -z-10 bg-background/50" />
      <motion.div
        style={{ x: reduced ? 0 : titleX, y: reduced ? 0 : titleY }}
        className="relative z-10 text-center"
      >
        <p className="mb-8 font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
          Beyond the familiar / 11
        </p>
        <h2 className="font-serif text-[clamp(4rem,14vw,11rem)] leading-none tracking-tight">
          Horizon
        </h2>
        <p className="mx-auto mt-8 max-w-xs text-sm leading-relaxed text-muted-foreground">
          A new perspective begins with the smallest shift.
        </p>
      </motion.div>
      <motion.div
        aria-hidden="true"
        style={{ x: reduced ? 0 : frontX, y: reduced ? 0 : frontY }}
        className="pointer-events-none absolute bottom-[5%] right-[5%] h-28 w-20 overflow-hidden rounded-lg border border-border sm:bottom-[10%] sm:right-[10%] sm:h-48 sm:w-36"
      >
        <img
          src="https://picsum.photos/seed/ardent-detail/400/600"
          alt=""
          className="h-full w-full object-cover"
        />
      </motion.div>
      <p className="absolute bottom-6 left-6 font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
        {reduced ? "A study in depth" : "Three planes / one perspective"}
      </p>
    </section>
  );
}
Next patternReading Progress Indicator→