Galleries & Media

Parallax Gallery

A grid whose tiles drift toward the pointer at per-tile depth; hovering lifts a tile in perspective with a moving specular sheen.

Install

npx shadcn@latest add @paragon/parallax-gallery

parallax-gallery.tsx

"use client";

import * as React from "react";
import {
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
} from "motion/react";
import { cn } from "@/lib/utils";

/**
 * ParallaxGallery — a grid whose tiles drift toward the pointer at per-tile
 * depth. Each tile is assigned a deterministic depth from its index; the shared
 * pointer offset is multiplied by that depth (and by the tile's grid position)
 * so nearer tiles travel further, producing layered mouse-parallax. Hovering a
 * tile lifts it on Z with a moving specular sheen. The whole grid also tilts
 * slightly in perspective toward the pointer.
 *
 * Pointer parallax is gated to fine pointers; on touch the grid sits still.
 * Reduced motion disables drift. Pass `items`; otherwise gradient tiles render.
 */
export interface ParallaxGalleryProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  items?: React.ReactNode[];
  /** Number of generated tiles when `items` is omitted. */
  count?: number;
  /** Grid columns. */
  columns?: number;
  /** Max parallax travel in px at full depth. */
  depth?: number;
}

const TILE_GRADIENTS = [
  "linear-gradient(135deg, oklch(0.72 0.16 250), oklch(0.52 0.2 285))",
  "linear-gradient(135deg, oklch(0.78 0.15 165), oklch(0.57 0.16 200))",
  "linear-gradient(135deg, oklch(0.82 0.16 70), oklch(0.66 0.19 40))",
  "linear-gradient(135deg, oklch(0.75 0.17 20), oklch(0.57 0.18 350))",
  "linear-gradient(135deg, oklch(0.76 0.13 300), oklch(0.54 0.16 264))",
  "linear-gradient(135deg, oklch(0.8 0.14 200), oklch(0.61 0.14 236))",
  "linear-gradient(135deg, oklch(0.8 0.15 130), oklch(0.61 0.17 160))",
  "linear-gradient(135deg, oklch(0.84 0.16 92), oklch(0.66 0.18 54))",
  "linear-gradient(135deg, oklch(0.74 0.15 330), oklch(0.55 0.18 300))",
];

// Deterministic depth factor in [0.4, 1] from index.
function depthOf(i: number) {
  const s = Math.sin(i * 12.9898) * 43758.5453;
  return 0.4 + (s - Math.floor(s)) * 0.6;
}

function GeneratedTile({ i }: { i: number }) {
  return (
    <div
      className="flex size-full items-end justify-start p-3"
      style={{ background: TILE_GRADIENTS[i % TILE_GRADIENTS.length] }}
    >
      <span className="text-sm font-semibold tabular-nums text-white/90 drop-shadow-[0_1px_3px_rgba(0,0,0,0.35)]">
        {String(i + 1).padStart(2, "0")}
      </span>
    </div>
  );
}

export function ParallaxGallery({
  items,
  count = 9,
  columns = 3,
  depth = 26,
  className,
  style,
  ...props
}: ParallaxGalleryProps) {
  const reduced = useReducedMotion() ?? false;
  const hostRef = React.useRef<HTMLDivElement>(null);
  const [fine, setFine] = React.useState(false);

  const tiles = React.useMemo<React.ReactNode[]>(() => {
    if (items && items.length) return items;
    return Array.from({ length: count }, (_, i) => (
      <GeneratedTile key={i} i={i} />
    ));
  }, [items, count]);

  // Pointer offset normalized to [-1, 1] from center.
  const px = useMotionValue(0);
  const py = useMotionValue(0);
  const sx = useSpring(px, { stiffness: 140, damping: 20, mass: 0.4 });
  const sy = useSpring(py, { stiffness: 140, damping: 20, mass: 0.4 });

  const tiltX = useTransform(sy, [-1, 1], [6, -6]);
  const tiltY = useTransform(sx, [-1, 1], [-6, 6]);

  React.useEffect(() => {
    if (typeof window === "undefined" || !window.matchMedia) return;
    const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
    const sync = () => setFine(mql.matches);
    sync();
    mql.addEventListener("change", sync);
    return () => mql.removeEventListener("change", sync);
  }, []);

  React.useEffect(() => {
    const el = hostRef.current;
    if (!el || !fine || reduced) return;
    const onMove = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      px.set(((e.clientX - rect.left) / rect.width) * 2 - 1);
      py.set(((e.clientY - rect.top) / rect.height) * 2 - 1);
    };
    const onLeave = () => {
      px.set(0);
      py.set(0);
    };
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerleave", onLeave);
    return () => {
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerleave", onLeave);
    };
  }, [fine, reduced, px, py]);

  return (
    <div
      ref={hostRef}
      className={cn("w-full", className)}
      style={{ perspective: 1000, ...style }}
      {...props}
    >
      <motion.div
        className="grid gap-3"
        style={{
          gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
          transformStyle: "preserve-3d",
          rotateX: reduced ? 0 : tiltX,
          rotateY: reduced ? 0 : tiltY,
        }}
      >
        {tiles.map((tile, i) => (
          <ParallaxTile
            key={i}
            index={i}
            sx={sx}
            sy={sy}
            depth={reduced ? 0 : depth}
            fine={fine}
          >
            {tile}
          </ParallaxTile>
        ))}
      </motion.div>
    </div>
  );
}

function ParallaxTile({
  index,
  sx,
  sy,
  depth,
  fine,
  children,
}: {
  index: number;
  sx: ReturnType<typeof useSpring>;
  sy: ReturnType<typeof useSpring>;
  depth: number;
  fine: boolean;
  children: React.ReactNode;
}) {
  const d = depthOf(index);
  const tx = useTransform(sx, (v) => v * depth * d);
  const ty = useTransform(sy, (v) => v * depth * d);
  const [hover, setHover] = React.useState(false);

  return (
    <motion.div
      onPointerEnter={() => fine && setHover(true)}
      onPointerLeave={(e) => {
        setHover(false);
        (e.currentTarget as HTMLElement).style.removeProperty("--sheen-x");
      }}
      onPointerMove={(e) => {
        if (!fine) return;
        const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
        (e.currentTarget as HTMLElement).style.setProperty(
          "--sheen-x",
          `${((e.clientX - r.left) / r.width) * 100}%`,
        );
      }}
      className="group relative aspect-square overflow-hidden rounded-xl shadow-overlay will-change-transform"
      style={{
        x: tx,
        y: ty,
        z: hover ? 40 : 0,
        transformStyle: "preserve-3d",
        transition: "z 240ms var(--ease-out)",
      }}
    >
      {children}
      {/* Specular sheen that tracks the pointer on hover. */}
      <span
        aria-hidden
        className="pointer-events-none absolute inset-0 opacity-0 mix-blend-overlay transition-opacity duration-200 group-hover:opacity-100"
        style={{
          background:
            "radial-gradient(140px 140px at var(--sheen-x, 50%) 0%, rgba(255,255,255,0.55), transparent 62%)",
        }}
      />
    </motion.div>
  );
}