# Depth Gallery (`depth-gallery`)

> A scroll-scrubbed Z-flythrough — plates hang scattered in 3D space and the scrollbar is the dolly; far plates loom out of the fog, near ones sweep past the camera.

- **Docs:** https://www.mellowui.com/components/depth-gallery
- **Markdown:** https://www.mellowui.com/components/depth-gallery.md
- **Registry:** https://www.mellowui.com/r/depth-gallery.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/depth-gallery
- **Categories:** display, scroll, 3d, animation
- **Dependencies:** motion

## AI prompt

Add a DepthGallery component from the mellow library — a scroll-driven 3D flythrough. Each item becomes a plate hanging in Z-space with a deterministic scatter; scrolling dollies the camera through them, fading far plates in from fog and sweeping near ones past the lens. Pass `items` (one ReactNode per plate — give each an opaque background), tune `spacing` for the Z gap, and for an embedded scroll area pass `scrollContainerRef` pointing at the overflow-y-auto ancestor. On the page scroll it just needs to be tall — it reserves items × viewport height. `hud` toggles the 01 / 05 progress readout. Falls back to a plain list under prefers-reduced-motion.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/depth-gallery.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `items` | `ReactNode[]` | — | One plane per item — the camera flies through them front to back. |
| `spacing` | `number` | `420` | Z distance between planes in px. |
| `scrollContainerRef` | `RefObject<HTMLElement \| null>` | — | Scrollable ancestor when embedded; defaults to the window. |
| `hud` | `boolean` | `true` | Show the 01 / 05 progress HUD. |
| `stiffness` | `number` | `90` | Camera-scrub spring stiffness. |
| `damping` | `number` | `24` | Camera-scrub spring damping. |
| `mass` | `number` | `0.65` | Camera-scrub spring mass. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useEffect, useRef, useState } from "react";
import {
  motion,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
} from "motion/react";

export interface DepthGalleryProps {
  /** One plane per item — the camera flies through them front to back. */
  items: React.ReactNode[];
  /** Z distance between planes in px. */
  spacing?: number;
  /** Scrollable ancestor when embedded; defaults to the window. */
  scrollContainerRef?: React.RefObject<HTMLElement | null>;
  /** Show the 01 / 05 progress HUD. */
  hud?: boolean;
  /** Camera-scrub spring stiffness. */
  stiffness?: number;
  /** Camera-scrub spring damping. */
  damping?: number;
  /** Camera-scrub spring mass. */
  mass?: number;
  className?: string;
}

function hash(n: number) {
  const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
  return s - Math.floor(s);
}

function smoothstep(from: number, to: number, value: number) {
  const t = Math.min(1, Math.max(0, (value - from) / (to - from)));
  return t * t * (3 - 2 * t);
}

/**
 * A scroll-scrubbed Z-flythrough — plates hang scattered in 3D space and the
 * scrollbar is the dolly: far plates loom out of the fog, near ones sweep
 * past the camera.
 */
export function DepthGallery({
  items,
  spacing = 420,
  scrollContainerRef,
  hud = true,
  stiffness = 90,
  damping = 24,
  mass = 0.65,
  className,
}: DepthGalleryProps) {
  const targetRef = useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();
  const [vh, setVh] = useState<number | null>(null);
  const n = items.length;

  useEffect(() => {
    const el = scrollContainerRef?.current;
    if (!el) return;
    const measure = () => setVh(el.clientHeight);
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, [scrollContainerRef]);

  const raw = useMotionValue(0);
  useEffect(() => {
    const container = scrollContainerRef?.current ?? null;
    const scroller: HTMLElement | Window = container ?? window;
    const update = () => {
      const el = targetRef.current;
      if (!el) return;
      const viewport = container ? container.clientHeight : window.innerHeight;
      const total = el.offsetHeight - viewport;
      const top = container
        ? el.getBoundingClientRect().top - container.getBoundingClientRect().top
        : el.getBoundingClientRect().top;
      raw.set(total > 0 ? Math.min(1, Math.max(0, -top / total)) : 0);
    };
    update();
    scroller.addEventListener("scroll", update, { passive: true });
    window.addEventListener("resize", update);
    return () => {
      scroller.removeEventListener("scroll", update);
      window.removeEventListener("resize", update);
    };
  }, [scrollContainerRef, raw, vh, n]);

  const progress = useSpring(raw, { stiffness, damping, mass });
  const cameraX = useTransform(progress, [0, 0.25, 0.5, 0.75, 1], [-22, 16, -12, 18, 0]);
  const cameraY = useTransform(progress, [0, 0.3, 0.65, 1], [10, -8, 8, 0]);
  const cameraPitch = useTransform(progress, [0, 0.3, 0.65, 1], [2.5, -1.5, 1.8, 0]);
  const cameraBank = useTransform(progress, [0, 0.25, 0.5, 0.75, 1], [-1.6, 1.2, -0.8, 1.3, 0]);

  const [current, setCurrent] = useState(1);
  useMotionValueEvent(progress, "change", (p) => {
    setCurrent(Math.min(n, Math.max(1, Math.round(p * (n - 1)) + 1)));
  });

  if (reduced) {
    return (
      <div className={className}>
        {items.map((item, i) => (
          <div key={i} className="mx-auto mb-6 w-[min(520px,78%)]">
            {item}
          </div>
        ))}
      </div>
    );
  }

  const vhUnit = vh ? `${vh}px` : "100dvh";

  return (
    <div
      ref={targetRef}
      className={["relative", className].filter(Boolean).join(" ")}
      style={{ height: `calc(${vhUnit} * ${n})` }}
    >
      <div
        className="sticky top-0 overflow-hidden"
        style={{ height: vhUnit, perspective: 900 }}
      >
        <motion.div
          className="absolute inset-0"
          style={{
            x: cameraX,
            y: cameraY,
            rotateX: cameraPitch,
            rotateZ: cameraBank,
            transformStyle: "preserve-3d",
          }}
        >
          {items.map((item, i) => (
            <Plane key={i} i={i} n={n} spacing={spacing} progress={progress}>
              {item}
            </Plane>
          ))}
        </motion.div>
        {hud && (
          <div className="absolute right-4 bottom-4 flex items-center gap-3">
            <span className="[font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.16em] text-[rgba(var(--ink-rgb),0.45)] tabular-nums">
              {String(current).padStart(2, "0")} / {String(n).padStart(2, "0")}
            </span>
            <div className="h-px w-16 bg-[rgba(var(--ink-rgb),0.15)]">
              <motion.div
                className="h-full origin-left bg-[var(--ink)]"
                style={{ scaleX: progress }}
              />
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

function Plane({
  i,
  n,
  spacing,
  progress,
  children,
}: {
  i: number;
  n: number;
  spacing: number;
  progress: MotionValue<number>;
  children: React.ReactNode;
}) {
  const sx = (hash(i) - 0.5) * 180;
  const sy = (hash(i + 50) - 0.5) * 100;

  const transform = useTransform(progress, (p) => {
    const z = (p * (n - 1) - i) * spacing;
    return `translate(-50%, -50%) translate3d(${sx}px, ${sy}px, ${z}px)`;
  });
  const opacity = useTransform(progress, (p) => {
    const z = (p * (n - 1) - i) * spacing;
    if (z >= 0) return 1 - smoothstep(0, spacing * 0.72, z);
    return 0.08 + 0.92 * (1 - smoothstep(0, spacing * 3.2, -z));
  });

  return (
    <motion.div
      className="absolute top-1/2 left-1/2 w-[min(520px,78%)]"
      style={{ transform, opacity }}
    >
      {children}
    </motion.div>
  );
}

export default DepthGallery;

```

## Demo

```tsx
"use client";

import React, { useRef } from "react";
import { DepthGallery } from "../mellow/depth-gallery";

export const DEPTH_GALLERY_PLATES = [
  {
    numeral: "I",
    title: "Low Tide",
    note: "The coast loosens its grip.",
    meta: "44.636° N · 1.249° W",
    image: "https://picsum.photos/id/1015/900/600",
  },
  {
    numeral: "II",
    title: "Tree Line",
    note: "Morning gathers beneath the canopy.",
    meta: "46.818° N · 8.227° E",
    image: "https://picsum.photos/id/1018/900/600",
  },
  {
    numeral: "III",
    title: "High Pass",
    note: "The road becomes a silver thread.",
    meta: "45.832° N · 6.865° E",
    image: "https://picsum.photos/id/1016/900/600",
  },
  {
    numeral: "IV",
    title: "Blue Hour",
    note: "Distance turns the ridgeline quiet.",
    meta: "59.913° N · 10.752° E",
    image: "https://picsum.photos/id/1020/900/600",
  },
  {
    numeral: "V",
    title: "Open Water",
    note: "The horizon keeps what came before.",
    meta: "62.007° N · 6.945° E",
    image: "https://picsum.photos/id/1043/900/600",
  },
];

/**
 * A single plate hanging in Z-space — photo above, caption line below.
 * Exported so the Lab renders the exact same plate as this demo.
 */
export function DepthPlate({ p }: { p: (typeof DEPTH_GALLERY_PLATES)[number] }) {
  return (
    <article className="overflow-hidden rounded-[0.9rem] border border-[var(--rule)] bg-[var(--background)] shadow-[0_40px_90px_-50px_rgba(0,0,0,0.6)]">
      <img
        src={p.image}
        alt={p.title}
        draggable={false}
        className="block aspect-[4/3] w-full object-cover saturate-[0.9] contrast-[1.03]"
      />
      <div className="flex items-center justify-between gap-3 px-4 py-3">
        <span className="[font-family:var(--font-serif)] text-lg text-[var(--ink)] italic">
          {p.title}
        </span>
        <span className="[font-family:var(--font-mono)] text-[0.5625rem] tracking-[0.16em] text-[rgba(var(--ink-rgb),0.45)] uppercase">
          {p.meta}
        </span>
      </div>
    </article>
  );
}

/** Doc preview — scroll the flythrough inside a fixed viewport. */
export default function DepthGalleryDemo() {
  const scrollRef = useRef<HTMLDivElement>(null);

  return (
    <div
      ref={scrollRef}
      className="h-[min(520px,70dvh)] w-full overflow-y-auto overscroll-contain"
    >
      <DepthGallery
        spacing={320}
        scrollContainerRef={scrollRef}
        className="w-full"
        items={DEPTH_GALLERY_PLATES.map((p) => (
          <DepthPlate key={p.numeral} p={p} />
        ))}
      />
    </div>
  );
}

```
