Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

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 cam…

MSource LandMellow UI

Why it stands out

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.

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11 (install: `pnpm add motion`)

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/depth-gallery-demo.tsx

File content: "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>
  );
}



File location: components/mellow/depth-gallery.tsx

File content: "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;



Usage notes:
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.

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI