A grid whose tiles drift toward the pointer at per-tile depth; hovering lifts a tile in perspective with a moving specular sheen.
npx shadcn@latest add @paragon/parallax-gallery"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>
);
}