Skip to content
jswnthui

Motion

Depth Image Card

A photo that parallaxes against the cursor by displacing its own pixels through a paired depth map, rendered on a three.js shader plane.

Move your cursor across it — the depth map drives the parallax

Installation

  1. 1. Install the dependencies.

    terminal
    npm install three @react-three/fiber @react-three/drei clsx tailwind-merge
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/depth-image-card.tsx
    'use client'
    
    import { Canvas, useFrame, useThree } from '@react-three/fiber'
    import { useTexture } from '@react-three/drei'
    import { Suspense, useMemo, useRef, type PointerEvent } from 'react'
    import * as THREE from 'three'
    
    const vertexShader = /* glsl */ `
      varying vec2 vUv;
      void main() {
        vUv = uv;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }
    `
    
    const fragmentShader = /* glsl */ `
      uniform sampler2D uColorMap;
      uniform sampler2D uDepthMap;
      uniform vec2 uMouse;
      uniform float uStrength;
      varying vec2 vUv;
    
      void main() {
        float depth = texture2D(uDepthMap, vUv).r;
        vec2 offset = uMouse * uStrength * depth;
        vec2 uv = clamp(vUv + offset, 0.001, 0.999);
        gl_FragColor = texture2D(uColorMap, uv);
      }
    `
    
    interface DepthPlaneProps {
      colorSrc: string
      depthSrc: string
      mouseTarget: React.RefObject<{ x: number; y: number }>
    }
    
    function DepthPlane({ colorSrc, depthSrc, mouseTarget }: DepthPlaneProps) {
      const [colorMap, depthMap] = useTexture([colorSrc, depthSrc])
      const materialRef = useRef<THREE.ShaderMaterial>(null)
      const mouseRef = useRef({ x: 0, y: 0 })
      const { viewport } = useThree()
    
      const uniforms = useMemo(
        () => ({
          uColorMap: { value: colorMap },
          uDepthMap: { value: depthMap },
          uMouse: { value: new THREE.Vector2(0, 0) },
          uStrength: { value: 0.05 },
        }),
        [colorMap, depthMap]
      )
    
      useFrame(() => {
        mouseRef.current.x += (mouseTarget.current.x - mouseRef.current.x) * 0.08
        mouseRef.current.y += (mouseTarget.current.y - mouseRef.current.y) * 0.08
        const mat = materialRef.current
        if (!mat) return
        mat.uniforms.uMouse.value.set(mouseRef.current.x, mouseRef.current.y)
      })
    
      return (
        <mesh scale={[viewport.width, viewport.height, 1]}>
          <planeGeometry args={[1, 1]} />
          <shaderMaterial
            ref={materialRef}
            vertexShader={vertexShader}
            fragmentShader={fragmentShader}
            uniforms={uniforms}
          />
        </mesh>
      )
    }
    
    interface DepthImageCardProps {
      colorSrc: string
      depthSrc: string
      className?: string
    }
    
    /** Depth-map-driven parallax tilt — same technique as the hero's LaptopDepthCard,
     * minus the fluid-cursor reveal lens, for a plain photo + depth map pair. */
    export default function DepthImageCard({ colorSrc, depthSrc, className }: DepthImageCardProps) {
      const mouseTarget = useRef({ x: 0, y: 0 })
    
      const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
        const rect = e.currentTarget.getBoundingClientRect()
        const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1
        const ny = ((e.clientY - rect.top) / rect.height) * 2 - 1
        mouseTarget.current = { x: nx, y: -ny }
      }
    
      const handlePointerLeave = () => {
        mouseTarget.current = { x: 0, y: 0 }
      }
    
      return (
        <div
          className={className}
          onPointerMove={handlePointerMove}
          onPointerLeave={handlePointerLeave}
        >
          <Canvas orthographic gl={{ antialias: true }} dpr={[1, 2]}>
            <Suspense fallback={null}>
              <DepthPlane colorSrc={colorSrc} depthSrc={depthSrc} mouseTarget={mouseTarget} />
            </Suspense>
          </Canvas>
        </div>
      )
    }

Props

PropTypeDefaultDescription
colorSrcstring—The photo itself.
depthSrcstring—Greyscale depth map, same dimensions — white is near, black is far.
classNamestring—Sizes the card. The canvas fills it.