Overview
A scene built from layers at different depths. Moving the pointer tilts the scene and shifts each layer by an amount set by its depth, so near layers travel further than far ones, and a soft light follows the pointer. As the page scrolls, layers drift at their own speeds. As the scene arrives, focus pulls from the background to the depth you choose, and layers in front of or behind that depth soften with a gentle blur. On first view the layers rise in from back to front.
Installation
Install dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}
Copy the component code into your project:
tsx
"use client"; import { useRef } from "react";import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";import { motion, useMotionTemplate, useMotionValue, useReducedMotion, useScroll, useSpring, useTransform } from "motion/react";import type { MotionValue } from "motion/react";import { cn } from "@/lib/utils"; export type DepthLayer = { id: string; content: ReactNode; /** 0 is the far background, 1 is closest to the viewer. */ depth?: number; /** @deprecated Pixel speed from the old API; converted to depth. */ speed?: number; className?: string;}; export type DepthStoryProps = { layers: DepthLayer[]; /** CSS height of the scene. */ height?: string; /** Strength of the pointer parallax. */ intensity?: number; /** Layers drift at different speeds as the page scrolls. */ scroll?: boolean; /** Tilt the whole scene toward the pointer. */ tilt?: boolean; /** Pull focus from the back to `focusDepth` as the scene scrolls in; other layers blur. */ rackFocus?: boolean; focusDepth?: number; className?: string;}; const POINTER = { stiffness: 120, damping: 20, mass: 0.6 }; export function DepthStory({ layers, height = "min(28rem, 72vh)", intensity = 1, scroll = true, tilt = true, rackFocus = true, focusDepth = 0.5, className,}: DepthStoryProps) { const reduce = !!useReducedMotion(); const root = useRef<HTMLDivElement>(null); // Pointer, normalised to -1…1 and smoothed. const rawX = useMotionValue(0); const rawY = useMotionValue(0); const px = useSpring(rawX, POINTER); const py = useSpring(rawY, POINTER); // 0 as the scene enters from below, 0.5 centred, 1 as it leaves. const { scrollYProgress } = useScroll({ target: root, offset: ["start end", "end start"] }); const s = useSpring(scrollYProgress, { stiffness: 200, damping: 40 }); const focus = useTransform(s, [0.12, 0.42], [0, focusDepth], { clamp: true }); const rotateX = useTransform(py, (v) => (tilt ? v * -3.5 : 0)); const rotateY = useTransform(px, (v) => (tilt ? v * 4.5 : 0)); const glareX = useTransform(px, (v) => 50 + v * 40); const glareY = useTransform(py, (v) => 50 + v * 40); const glare = useMotionTemplate`radial-gradient(520px circle at ${glareX}% ${glareY}%, color-mix(in oklch, var(--color-paper) 22%, transparent), transparent 60%)`; const onMove = (e: ReactPointerEvent<HTMLDivElement>) => { if (reduce || e.pointerType !== "mouse" || !root.current) return; const r = root.current.getBoundingClientRect(); rawX.set(((e.clientX - r.left) / r.width - 0.5) * 2); rawY.set(((e.clientY - r.top) / r.height - 0.5) * 2); }; const onLeave = () => { rawX.set(0); rawY.set(0); }; return ( <div ref={root} onPointerMove={onMove} onPointerLeave={onLeave} className={cn("relative isolate w-full [perspective:1200px]", className)} style={{ height }} > <motion.div style={reduce ? undefined : { rotateX, rotateY }} className="relative size-full overflow-hidden rounded-[24px] bg-(--color-paper-2) shadow-[0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.04),0_24px_60px_-28px_rgba(0,0,0,0.35)]" > {layers.map((layer, i) => ( <Layer key={layer.id} layer={layer} index={i} px={px} py={py} s={s} focus={focus} intensity={intensity} scroll={scroll} rackFocus={rackFocus} reduce={reduce} /> ))} {!reduce ? <motion.div aria-hidden className="pointer-events-none absolute inset-0 mix-blend-soft-light" style={{ backgroundImage: glare }} /> : null} <div aria-hidden className="pointer-events-none absolute inset-0 rounded-[24px] shadow-[inset_0_1px_0_color-mix(in_oklch,var(--color-paper)_40%,transparent)]" /> </motion.div> </div> );} function Layer({ layer, index, px, py, s, focus, intensity, scroll, rackFocus, reduce,}: { layer: DepthLayer; index: number; px: MotionValue<number>; py: MotionValue<number>; s: MotionValue<number>; focus: MotionValue<number>; intensity: number; scroll: boolean; rackFocus: boolean; reduce: boolean;}) { const depth = Math.max(0, Math.min(1, layer.depth ?? (layer.speed !== undefined ? layer.speed / 30 : index / 4))); // Nearer layers travel further, both with the pointer and with the scroll. const x = useTransform(() => px.get() * depth * 26 * intensity); const y = useTransform(() => py.get() * depth * 16 * intensity + (scroll ? (s.get() - 0.5) * -depth * 160 : 0)); const scale = useTransform(() => 1 + depth * 0.05 + (scroll ? (s.get() - 0.5) * depth * 0.1 : 0)); const filter = useTransform(() => { const b = rackFocus ? Math.min(4.5, Math.abs(depth - focus.get()) * 6.5) : 0; return b < 0.15 ? "none" : `blur(${b.toFixed(2)}px)`; }); return ( <motion.div aria-hidden={layer.content == null || undefined} style={reduce ? undefined : { x, y, scale, filter }} // Oversized so edges never show while layers drift. className="absolute -inset-[8%] will-change-transform" > <motion.div initial={reduce ? false : { opacity: 0, y: 24 * (depth + 0.2) }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, amount: 0.3 }} transition={{ duration: 0.9, ease: [0.23, 1, 0.32, 1], delay: reduce ? 0 : 0.08 + depth * 0.25 }} className={cn("absolute inset-[6.9%] flex items-center justify-center", layer.className)} > {layer.content} </motion.div> </motion.div> );}
Adjust import paths according to your folder structure.
Basic Usage
tsx
import { DepthStory } from "@/components/ui/depth-story"; <DepthStory focusDepth={0.5} layers={[ { id: "sky", depth: 0, content: <Sky /> }, { id: "hills", depth: 0.3, content: <Hills /> }, { id: "title", depth: 0.5, content: <Title /> }, { id: "foreground", depth: 0.9, content: <Foreground /> }, ]}/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| layers | { id, content, depth?, className? }[] | Yes | — | Back to front. depth runs from 0 (far) to 1 (near). |
| height | string | No | "min(28rem, 72vh)" | CSS height of the scene. |
| intensity | number | No | 1 | Strength of the pointer parallax. |
| scroll | boolean | No | true | Layers drift at different speeds as the page scrolls. |
| tilt | boolean | No | true | Tilt the scene toward the pointer. |
| rackFocus | boolean | No | true | Pull focus from the back to focusDepth as the scene scrolls in. |
| focusDepth | number | No | 0.5 | Depth that ends up sharp. Put your text layer here. |
| className | string | No | — | Classes for the wrapper. |
Best Practices
- Three to six layers is plenty. Give each a clearly different depth so the motion reads.
- Put text on the
focusDepthlayer so it is always the sharpest thing in the scene. - Layers are drawn about 16% larger than the frame so their edges never show; keep important content away from the outer edges.
- Pointer effects only run with a mouse. Touch still gets the scroll drift, and reduced motion shows a still, sharp scene.