Skip to content

Pointer Parallax Scene

A depth system where layers separate under the pointer from one shared motion source.

Freeglassminimaleditorial
Category
Animations
Added
2026-09-20
Deps
1
Updated
2026-09-20
Preview
DepthParallax Scene

Props

1
140
Tilt foreground
Requires framer-motion
"use client";

import { createContext, useContext, useRef, type ReactNode } from "react";
import {
  motion,
  useMotionValue,
  useSpring,
  useTransform,
  type MotionValue,
} from "framer-motion";

export function PointerParallaxScene({
  children,
  intensity = 1,
  stiffness = 140,
  tiltCard = true,
}: {
  children: ReactNode;
  /** Multiplies every layer's depth. 0 pins the whole scene flat. */
  intensity?: number;
  stiffness?: number;
  /** Bank the scene contents in 3D as well as offsetting them. */
  tiltCard?: boolean;
}) {
  const boxRef = useRef<HTMLDivElement>(null);

  // ONE normalised -0.5..0.5 pointer source, shared by every layer. Parallax
  // is just this value times each layer's own depth factor.
  const px = useMotionValue(0);
  const py = useMotionValue(0);
  const sx = useSpring(px, { stiffness, damping: 22, mass: 0.6 });
  const sy = useSpring(py, { stiffness, damping: 22, mass: 0.6 });

  const rotateY = useTransform(sx, [-0.5, 0.5], [-8, 8]);
  const rotateX = useTransform(sy, [-0.5, 0.5], [6, -6]);

  return (
    <ParallaxContext.Provider value={{ sx, sy, intensity }}>
      <div
        ref={boxRef}
        onMouseMove={(e) => {
          const rect = boxRef.current?.getBoundingClientRect();
          if (!rect) return;
          px.set((e.clientX - rect.left) / rect.width - 0.5);
          py.set((e.clientY - rect.top) / rect.height - 0.5);
        }}
        onMouseLeave={() => {
          px.set(0);
          py.set(0);
        }}
        className="relative overflow-hidden"
        style={{ perspective: 800 }}
      >
        <motion.div
          style={tiltCard ? { rotateX, rotateY } : undefined}
          className="relative h-full w-full"
        >
          {children}
        </motion.div>
      </div>
    </ParallaxContext.Provider>
  );
}

const ParallaxContext = createContext<{
  sx: MotionValue<number>;
  sy: MotionValue<number>;
  intensity: number;
} | null>(null);

export function ParallaxLayer({
  depth,
  className,
  children,
}: {
  /** Negative = behind, positive = in front. 0 is pinned to the screen plane. */
  depth: number;
  className?: string;
  children?: ReactNode;
}) {
  const ctx = useContext(ParallaxContext);
  if (!ctx) throw new Error("ParallaxLayer must be inside PointerParallaxScene");

  // useTransform has to run at a component's top level, which is why a layer
  // is its own component rather than a callback inside a .map().
  // The scene's intensity scales every layer from one place, so a single
  // control flattens or exaggerates the whole composition.
  const scaled = depth * ctx.intensity;
  const x = useTransform(ctx.sx, (v) => v * scaled * 2);
  const y = useTransform(ctx.sy, (v) => v * scaled * 2);

  return (
    <motion.div aria-hidden style={{ x, y }} className={"absolute " + className}>
      {children}
    </motion.div>
  );
}
Notes

About this animation

Pointer parallax is usually rebuilt per element — every layer attaches its own mousemove handler and keeps its own state — which multiplies listeners, desynchronises the layers under load, and re-renders the scene on every pixel. The better shape is one normalised pointer value at the container, smoothed by a spring, shared through context, with each layer deriving its own translation as that value times its depth. All layers then move from the same sample and stay in lockstep by construction, and because everything rides motion values rather than state, moving the pointer does not enter React at all. The structural detail worth understanding before adapting it: a layer has to be its own component rather than a callback inside a map, because `useTransform` is a hook and must be called at a component's top level — calling it inside a map callback is a rules-of-hooks violation as soon as the layer list can change length. Depth is signed, with negative behind and positive in front, which is the inverse of real optics but matches how designers actually reason about foreground and background. Layers are decorative, so nothing meaningful should live only in them.

Curator’s note

Built for viberdy 2.0 as the pointer-driven counterpart to the library's scroll parallax entries. One shared motion source, many derived layers.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

NewPro

A parallax scene where every layer derives from one shared scroll source.

scrollparallaxdepth
Pro

Skews its content based on how fast the page is being scrolled, relaxing back to flat at rest.

scrollvelocityskew
Pro

Reveals text one line at a time, each sliding up from behind its own clipping mask.

text-revealmaskscroll