A photographer's archive you look through, not scroll past

A grid gives every photograph the same weight, so nothing is in front. Daniel's site needed the opposite.

WhenFeb –– Sep '26
ForDaniel Derro / RGL
DisciplineDesign engineering, front-end, creative portfolio
ToolsNext.js, React, Sanity, framer-motion

Problem

Daniel Derro is a photographer and director. He shoots campaigns and editorials on medium format film for brands like Nike, Prada and Stüssy, and runs No School Studios, a visual practice in Venice.

His site holds twenty-three projects, nine of them films, plus a gallery of stills pulled from all of them. A grid gives every one of those frames the same size and the same distance from you. On a photographer's site that's the one thing the work can't afford: nothing is in front.

He grew up in Venice, started out drawing, and says photography saved his life. His 8mm film and book Life in Debt was made to be seen only in a physical space. A website can't be a room, but it can stop behaving like a feed.

I met Daniel in the fall of 2025, just after moving to LA. Malik Laing, whose site I also built, had assisted him on a shoot and showed him my work. Then Malik brought me to Serving the People, the creative discussion group run by the late Lucien Smith, whose LA chapter Daniel runs. Daniel called me that night and we talked for a couple of hours. He's been a mentor since, and he's introduced me to other clients.

The home page is three lines over a film. The work starts one click in.

Thinking

The photographs are the product, so every decision started from not making them worse. Project slides render at quality 90 up to 2560px wide, and the home film stays H.264 at a higher quality than the default.

The site went through two versions in production before this one. In April it was a white page with red type and one photograph. By June the home was a film, the stills sat in an even grid, and the work page was numbered rows, every row at full brightness.

Every still in that grid was the same size, which is the problem the wall solves. And pointing at a row changed nothing yet.

Paths not taken

What I left out matters as much: no hover zoom, no tint, no border drawn around a photo. The interface moves photographs around, and never draws on one.

A wall you look through

The gallery is an endless wall of stills, seen through a dome. The frame in the middle swells toward you and the edges fall away, so whatever sits in the center is what you're looking at. Drag and it keeps sliding when you let go.

Show code
dome-gallery.tsx
"use client";

import {
  memo,
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  useSyncExternalStore,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import { cubicBezier } from "@/lib/daniel-derro/motion";
import {
  dampingAlpha,
  SMOOTH_SCROLL_LERP,
  wheelDeltaPx,
} from "@/lib/daniel-derro/smooth-scroll";
import {
  layoutGalleryBarrel,
  poolCopies,
  projectDomeTile,
  wrapCoord,
  type BarrelLayoutItem,
  type BarrelTile,
  type BarrelWorld,
} from "./gallery-barrel";

const HOVER_HOLD_MS = 450;
const DRAG_CLICK_PX = 6;
/** Extra px around the viewport before a pooled tile is hidden. */
const CULL_MARGIN = 320;
/** px/frame below this stays sharp; above it maps into the blur range. */
const MOTION_BLUR_DEADZONE = 8;
const MOTION_BLUR_GAIN = 0.12;
const MOTION_BLUR_MAX = 6;
const KEY_STEP = 72;

function motionBlurPx(speed: number): number {
  if (speed < MOTION_BLUR_DEADZONE) return 0;
  return Math.min(MOTION_BLUR_MAX, (speed - MOTION_BLUR_DEADZONE) * MOTION_BLUR_GAIN);
}

function motionBlurFilter(blurPx: number): string {
  return blurPx < 0.35 ? "none" : `blur(${blurPx.toFixed(1)}px)`;
}

const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";

function subscribeReducedMotion(onChange: () => void) {
  const mq = window.matchMedia(REDUCED_MOTION);
  mq.addEventListener("change", onChange);
  return () => mq.removeEventListener("change", onChange);
}

function useReducedMotion(): boolean {
  return useSyncExternalStore(
    subscribeReducedMotion,
    () => window.matchMedia(REDUCED_MOTION).matches,
    () => false,
  );
}

export type DomeGalleryTile = BarrelTile & {
  /** Accessible name of the tile, and the default hover caption. */
  label: string;
};

type TileNode = {
  el: HTMLDivElement;
  media: HTMLElement | null;
  item: BarrelLayoutItem<DomeGalleryTile>;
  cj: number;
  ck: number;
};

export type DomeGalleryProps<T extends DomeGalleryTile> = {
  tiles: T[];
  /** Tile contents. Fills a box of the tile's aspect ratio. */
  renderTile: (tile: T) => ReactNode;
  /** Caption shown at the stage center after the pointer rests on a tile. */
  renderMeta?: (tile: T) => ReactNode;
  /** Makes tiles buttons. Ignored when the press ended a drag. */
  onSelect?: (tile: T) => void;
  columns?: number;
  /** Column width in px; heights follow each tile's aspect. */
  tileWidth?: number;
  gap?: number;
  /** CSS perspective in px. Lower is a stronger fisheye. */
  perspective?: number;
  /** Crest height in px: translateZ at the stage center. */
  bulge?: number;
  /** Dome width as a fraction of the smaller stage dimension. */
  spread?: number;
  /** Rotation gain. 1 keeps tiles tangent to the dome surface. */
  curve?: number;
  /** Corner radius at the crest, in px. */
  round?: number;
  /** Per-frame velocity retention after a throw (0.82–0.985). */
  inertia?: number;
  /** Render a flat plane. Always flat under prefers-reduced-motion. */
  flat?: boolean;
  /**
   * "always" pans on any wheel over the stage (a full-page canvas).
   * "focus" pans by wheel only once the stage has focus, and lets vertical
   * touch swipes scroll the page until then, so an embedded canvas never
   * traps page scroll.
   */
  wheel?: "always" | "focus";
  className?: string;
  "aria-label"?: string;
};

/**
 * Fixed pool of tile nodes: every tile is mounted once per toroidal copy and
 * never unmounts while panning. The paint loop only moves existing nodes, so
 * media never remounts or re-decodes.
 */
const TilePool = memo(function TilePool({
  world,
  copiesX,
  copiesY,
  bind,
  renderTile,
  onHover,
  onLeave,
  onSelect,
  isDragClick,
}: {
  world: BarrelWorld<DomeGalleryTile>;
  copiesX: number;
  copiesY: number;
  bind: (
    key: string,
    item: BarrelLayoutItem<DomeGalleryTile>,
    cj: number,
    ck: number,
  ) => (el: HTMLDivElement | null) => void;
  renderTile: (tile: DomeGalleryTile) => ReactNode;
  onHover: (tileKey: string, pointerType: string) => void;
  onLeave: () => void;
  onSelect?: (tile: DomeGalleryTile) => void;
  isDragClick: () => boolean;
}) {
  const tiles: ReactNode[] = [];
  for (const item of world.items) {
    const content = renderTile(item.tile);
    for (let cj = 0; cj < copiesX; cj += 1) {
      for (let ck = 0; ck < copiesY; ck += 1) {
        const key = `${item.key}@${cj}:${ck}`;
        const primary = cj === 0 && ck === 0;
        const media = (
          <span
            data-dome-media=""
            className={cn(
              "relative block size-full overflow-hidden rounded-[inherit]",
              "transition-opacity duration-[900ms] motion-reduce:transition-none",
              "group-data-[dimmed=true]/tile:opacity-[0.18]",
            )}
            style={{ transitionTimingFunction: cubicBezier("heavy") }}
          >
            {content}
          </span>
        );
        const shared = {
          className:
            "block size-full rounded-[inherit] text-inherit outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          onPointerEnter: (event: React.PointerEvent) => onHover(item.tile.key, event.pointerType),
          onPointerLeave: onLeave,
        };
        tiles.push(
          <div
            key={key}
            ref={bind(key, item, cj, ck)}
            className="group/tile absolute left-0 top-0 origin-center"
            style={{ width: item.w, height: item.h, visibility: "hidden" }}
          >
            {onSelect ? (
              <button
                type="button"
                {...shared}
                aria-label={item.tile.label}
                tabIndex={primary ? undefined : -1}
                aria-hidden={primary ? undefined : true}
                draggable={false}
                onClick={() => {
                  if (!isDragClick()) onSelect(item.tile);
                }}
              >
                {media}
              </button>
            ) : (
              <div
                {...shared}
                role={primary ? "img" : undefined}
                aria-label={primary ? item.tile.label : undefined}
                aria-hidden={primary ? undefined : true}
              >
                {media}
              </div>
            )}
          </div>,
        );
      }
    }
  }
  return <>{tiles}</>;
});

/**
 * An infinite, toroidally wrapped masonry wall seen through a Gaussian dome:
 * tiles near the center bulge toward the camera and roll onto the curve.
 * Drag (with inertia), wheel, or arrow keys pan; fast pans motion-blur.
 */
export function DomeGallery<T extends DomeGalleryTile>({
  tiles,
  renderTile,
  renderMeta,
  onSelect,
  columns = 8,
  tileWidth = 180,
  gap = 40,
  perspective = 2500,
  bulge = 690,
  spread = 1,
  curve = 0.23,
  round = 0,
  inertia = 0.9,
  flat = false,
  wheel = "always",
  className,
  "aria-label": ariaLabel = "Infinite gallery canvas. Drag, scroll, or use arrow keys to pan.",
}: DomeGalleryProps<T>) {
  const reduceMotion = useReducedMotion();
  const flatten = flat || reduceMotion;

  const world = useMemo(
    () => layoutGalleryBarrel<DomeGalleryTile>(tiles, columns, tileWidth, gap),
    [tiles, columns, tileWidth, gap],
  );
  const [copies, setCopies] = useState({ x: 1, y: 1 });

  const stageRef = useRef<HTMLDivElement>(null);
  const nodes = useRef(new Map<string, TileNode>());
  const pan = useRef({ x: 0, y: 0, vx: 0, vy: 0 });
  /** Wheel/keys steer this; the pan glides toward it each frame. */
  const wheelTarget = useRef({ x: 0, y: 0, active: false });
  const lastTick = useRef(0);
  const lastPan = useRef({ x: 0, y: 0 });
  const speedRef = useRef(0);
  const dragging = useRef(false);
  const dragMoved = useRef(false);
  const pointer = useRef({ id: -1, x: 0, y: 0 });
  /** Screen px per layout px, so a scaled-down stage still follows the hand 1:1. */
  const screenScale = useRef(1);
  const raf = useRef(0);
  const view = useRef({ w: 0, h: 0 });
  const optics = useMemo(() => ({ bulge, spread, curve, round }), [bulge, spread, curve, round]);
  const opticsRef = useRef(optics);
  const inertiaRef = useRef(inertia);
  const worldRef = useRef(world);
  const flattenRef = useRef(flatten);
  const wheelModeRef = useRef(wheel);
  useLayoutEffect(() => {
    opticsRef.current = optics;
    inertiaRef.current = inertia;
    worldRef.current = world;
    flattenRef.current = flatten;
    wheelModeRef.current = wheel;
  });

  // In "focus" mode the stage only owns wheel and vertical touch once engaged.
  const [engaged, setEngaged] = useState(false);
  const [hoveredKey, setHoveredKey] = useState<string | null>(null);
  const hoverTimer = useRef<number | null>(null);
  const hoveredTile = tiles.find((tile) => tile.key === hoveredKey) ?? null;
  // Keep the last caption mounted while it fades out.
  const [lastHovered, setLastHovered] = useState<T | null>(null);
  if (hoveredTile && hoveredTile !== lastHovered) setLastHovered(hoveredTile);
  const meta = hoveredTile ?? lastHovered;

  const clearHoverTimer = useCallback(() => {
    if (hoverTimer.current !== null) {
      window.clearTimeout(hoverTimer.current);
      hoverTimer.current = null;
    }
  }, []);

  const requestHover = useCallback(
    (key: string | null) => {
      if (dragging.current) return;
      clearHoverTimer();
      if (!key) {
        setHoveredKey(null);
        return;
      }
      hoverTimer.current = window.setTimeout(() => setHoveredKey(key), HOVER_HOLD_MS);
    },
    [clearHoverTimer],
  );

  // Touch has no hover: a tap would otherwise dim the wall under the finger.
  const onTileHover = useCallback(
    (tileKey: string, pointerType: string) => {
      if (pointerType !== "touch") requestHover(tileKey);
    },
    [requestHover],
  );
  const onTileLeave = useCallback(() => requestHover(null), [requestHover]);
  const isDragClick = useCallback(() => dragMoved.current, []);

  useEffect(() => clearHoverTimer, [clearHoverTimer]);

  // Hover dim writes straight to the pooled nodes: no React pass over tiles.
  useEffect(() => {
    nodes.current.forEach(({ el, item }) => {
      if (hoveredKey !== null && item.tile.key !== hoveredKey) el.dataset.dimmed = "true";
      else delete el.dataset.dimmed;
    });
  }, [hoveredKey]);

  const bindTile = useCallback(
    (key: string, item: BarrelLayoutItem<DomeGalleryTile>, cj: number, ck: number) =>
      (el: HTMLDivElement | null) => {
        if (el) {
          nodes.current.set(key, {
            el,
            media: el.querySelector("[data-dome-media]"),
            item,
            cj,
            ck,
          });
        } else {
          nodes.current.delete(key);
        }
      },
    [],
  );

  const measure = useCallback(() => {
    const node = stageRef.current;
    if (!node) return;
    view.current.w = node.clientWidth;
    view.current.h = node.clientHeight;
  }, []);

  const syncCopies = useCallback(() => {
    const next = poolCopies(worldRef.current, view.current.w, view.current.h);
    setCopies((prev) => (prev.x === next.x && prev.y === next.y ? prev : next));
  }, []);

  /** Position every pooled node for the current pan. Pure DOM writes. */
  const paint = useCallback(() => {
    const { w: vw, h: vh } = view.current;
    const flat = flattenRef.current;
    const opticsNow = opticsRef.current;
    const W = worldRef.current.width;
    const panX = pan.current.x;
    const panY = pan.current.y;
    const blurPx = flat ? 0 : motionBlurPx(speedRef.current);
    const filter = motionBlurFilter(blurPx);

    nodes.current.forEach(({ el, media, item, cj, ck }) => {
      const P = item.periodY;
      const sx0 = W > 0 ? wrapCoord(item.x - panX, W) : item.x - panX;
      const sy0 = P > 0 ? wrapCoord(item.y - panY, P) : item.y - panY;
      const sx = sx0 + (cj - 1) * W;
      const sy = sy0 + (ck - 1) * P;

      if (
        sx + item.w < -CULL_MARGIN ||
        sx > vw + CULL_MARGIN ||
        sy + item.h < -CULL_MARGIN ||
        sy > vh + CULL_MARGIN
      ) {
        if (el.style.visibility !== "hidden") {
          el.style.visibility = "hidden";
          if (media && media.style.filter !== "none") media.style.filter = "none";
        }
        return;
      }

      const t = projectDomeTile(sx, sy, item.w, item.h, vw, vh, opticsNow, flat);
      el.style.visibility = "visible";
      el.style.transform = `translate3d(${t.x}px, ${t.y}px, ${t.z}px) rotateX(${t.rotateX}deg) rotateY(${t.rotateY}deg)`;
      el.style.borderRadius = `${t.radius}px`;
      if (media && media.style.filter !== filter) media.style.filter = filter;
    });
  }, []);

  const wrapPan = useCallback(() => {
    // X wraps at the shared period; Y wraps per column inside paint, so panY
    // stays unbounded (pixel floats are exact far beyond any session's travel).
    const W = worldRef.current.width;
    if (W > 0) {
      const wrapped = wrapCoord(pan.current.x, W);
      // Shift the wheel target by the same period so the glide is unaffected.
      wheelTarget.current.x += wrapped - pan.current.x;
      pan.current.x = wrapped;
    }
  }, []);

  /** Steer from wheel / keys: damped glide, or direct under reduced motion. */
  const nudge = useCallback((dx: number, dy: number) => {
    pan.current.vx = 0;
    pan.current.vy = 0;
    if (flattenRef.current) {
      wheelTarget.current.active = false;
      pan.current.x += dx;
      pan.current.y += dy;
      return;
    }
    const target = wheelTarget.current;
    if (!target.active) {
      target.x = pan.current.x;
      target.y = pan.current.y;
      target.active = true;
    }
    target.x += dx;
    target.y += dy;
  }, []);

  const tickRef = useRef<(now: number) => void>(() => {});
  const tick = useCallback(
    (now: number) => {
      raf.current = 0;
      const dt = Math.min((now - lastTick.current) / 1000, 0.1);
      lastTick.current = now;

      if (!dragging.current) {
        const target = wheelTarget.current;
        if (target.active) {
          const alpha = dampingAlpha(SMOOTH_SCROLL_LERP, dt);
          const remainingX = target.x - pan.current.x;
          const remainingY = target.y - pan.current.y;
          if (Math.hypot(remainingX, remainingY) < 0.3) {
            pan.current.x = target.x;
            pan.current.y = target.y;
            target.active = false;
          } else {
            pan.current.x += remainingX * alpha;
            pan.current.y += remainingY * alpha;
          }
        } else {
          pan.current.x += pan.current.vx;
          pan.current.y += pan.current.vy;
          const damp = inertiaRef.current;
          pan.current.vx *= damp;
          pan.current.vy *= damp;
          if (Math.abs(pan.current.vx) < 0.04) pan.current.vx = 0;
          if (Math.abs(pan.current.vy) < 0.04) pan.current.vy = 0;
        }
      }

      // Measure before wrap: wrapping X by a world period would look like a
      // huge jump and spike the blur.
      const instant = Math.hypot(
        pan.current.x - lastPan.current.x,
        pan.current.y - lastPan.current.y,
      );
      wrapPan();
      lastPan.current.x = pan.current.x;
      lastPan.current.y = pan.current.y;

      speedRef.current = flattenRef.current ? 0 : speedRef.current * 0.72 + instant * 0.28;
      if (speedRef.current < 0.15) speedRef.current = 0;

      paint();

      const coasting =
        !dragging.current &&
        (pan.current.vx !== 0 || pan.current.vy !== 0 || wheelTarget.current.active);
      if (dragging.current || coasting || speedRef.current > 0) {
        raf.current = window.requestAnimationFrame(tickRef.current);
      }
    },
    [paint, wrapPan],
  );
  useLayoutEffect(() => {
    tickRef.current = tick;
  }, [tick]);

  const startLoop = useCallback(() => {
    if (raf.current) return;
    lastTick.current = performance.now();
    raf.current = window.requestAnimationFrame(tickRef.current);
  }, []);

  // Re-measure, size the pool, and repaint whenever layout inputs change.
  // Reveal only after the copy pool has settled, with the dome already
  // written: the veil then fades over a live 3D scene.
  useLayoutEffect(() => {
    measure();
    const next = poolCopies(worldRef.current, view.current.w, view.current.h);
    if (copies.x !== next.x || copies.y !== next.y) {
      setCopies(next);
      paint();
      return;
    }
    paint();
    const stage = stageRef.current;
    if (stage && view.current.w > 0 && view.current.h > 0) {
      stage.dataset.ready = "true";
    }
  }, [measure, paint, world, copies, optics, flatten]);

  useEffect(() => {
    const node = stageRef.current;
    if (!node) return;
    const observer = new ResizeObserver(() => {
      measure();
      syncCopies();
      paint();
    });
    observer.observe(node);

    const onWheel = (event: WheelEvent) => {
      if (event.ctrlKey) return;
      if (wheelModeRef.current === "focus" && !node.contains(document.activeElement)) return;
      event.preventDefault();
      const { dx, dy } = wheelDeltaPx(event);
      nudge(dx, dy);
      startLoop();
    };
    node.addEventListener("wheel", onWheel, { passive: false });
    return () => {
      observer.disconnect();
      node.removeEventListener("wheel", onWheel);
    };
  }, [measure, syncCopies, paint, startLoop, nudge]);

  useEffect(
    () => () => {
      if (raf.current) window.cancelAnimationFrame(raf.current);
    },
    [],
  );

  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return;
    dragMoved.current = false;
    dragging.current = false;
    pointer.current = { id: event.pointerId, x: event.clientX, y: event.clientY };
    const box = event.currentTarget;
    screenScale.current = box.clientWidth > 0 ? box.getBoundingClientRect().width / box.clientWidth : 1;
    pan.current.vx = 0;
    pan.current.vy = 0;
    // The hand takes over from any wheel glide still in flight.
    wheelTarget.current.active = false;
  };

  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.pointerId !== pointer.current.id) return;
    const scale = screenScale.current || 1;
    const dx = (event.clientX - pointer.current.x) / scale;
    const dy = (event.clientY - pointer.current.y) / scale;
    if (!dragging.current) {
      if (Math.hypot(dx, dy) * scale <= DRAG_CLICK_PX) return;
      dragging.current = true;
      dragMoved.current = true;
      event.currentTarget.setPointerCapture(event.pointerId);
      event.currentTarget.dataset.dragging = "true";
      clearHoverTimer();
      setHoveredKey(null);
      startLoop();
    }
    pan.current.x -= dx;
    pan.current.y -= dy;
    pan.current.vx = -dx;
    pan.current.vy = -dy;
    pointer.current.x = event.clientX;
    pointer.current.y = event.clientY;
  };

  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.pointerId !== pointer.current.id) return;
    const wasDragging = dragging.current;
    dragging.current = false;
    pointer.current.id = -1;
    event.currentTarget.dataset.dragging = "false";
    if (wasDragging && event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
    if (flattenRef.current) {
      pan.current.vx = 0;
      pan.current.vy = 0;
    }
    if (pan.current.vx !== 0 || pan.current.vy !== 0 || speedRef.current > 0) {
      startLoop();
    } else {
      wrapPan();
      lastPan.current.x = pan.current.x;
      lastPan.current.y = pan.current.y;
      paint();
    }
  };

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "ArrowLeft") nudge(-KEY_STEP, 0);
    else if (event.key === "ArrowRight") nudge(KEY_STEP, 0);
    else if (event.key === "ArrowUp") nudge(0, -KEY_STEP);
    else if (event.key === "ArrowDown") nudge(0, KEY_STEP);
    else return;
    event.preventDefault();
    startLoop();
  };

  return (
    <div className={cn("relative size-full overflow-hidden bg-background", className)}>
      <div
        ref={stageRef}
        className={cn(
          "group/stage absolute inset-0 cursor-grab select-none overflow-hidden outline-none",
          "data-[dragging=true]:cursor-grabbing",
          "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
        )}
        style={{
          perspective: flatten ? undefined : `${perspective}px`,
          perspectiveOrigin: "50% 50%",
          // Until engaged, vertical swipes scroll the page past an embedded
          // canvas; horizontal drags still pan it.
          touchAction: wheel === "focus" && !engaged ? "pan-y pinch-zoom" : "none",
        }}
        tabIndex={0}
        role="group"
        aria-roledescription="canvas"
        aria-label={ariaLabel}
        data-dragging="false"
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        onKeyDown={onKeyDown}
        onFocus={() => setEngaged(true)}
        onBlur={(event) => {
          if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setEngaged(false);
        }}
        onMouseLeave={onTileLeave}
      >
        <div className="absolute inset-0" style={{ transformStyle: "preserve-3d" }}>
          <TilePool
            world={world}
            copiesX={copies.x}
            copiesY={copies.y}
            bind={bindTile}
            renderTile={renderTile as (tile: DomeGalleryTile) => ReactNode}
            onHover={onTileHover}
            onLeave={onTileLeave}
            onSelect={onSelect as ((tile: DomeGalleryTile) => void) | undefined}
            isDragClick={isDragClick}
          />
        </div>
        {/* Fade a veil, not the 3D scene: opacity on a preserve-3d ancestor
            flattens the dome until the fade finishes. */}
        <div
          aria-hidden
          className={cn(
            "absolute inset-0 z-[5] bg-background opacity-100",
            "transition-opacity duration-[800ms] motion-reduce:transition-none",
            "group-data-[ready=true]/stage:pointer-events-none group-data-[ready=true]/stage:opacity-0",
          )}
          style={{ transitionTimingFunction: cubicBezier("heavy") }}
        />
      </div>

      <div
        aria-hidden={!hoveredTile}
        className="pointer-events-none absolute inset-x-4 top-1/2 z-10 -translate-y-1/2 text-center text-sm text-foreground transition-opacity motion-reduce:transition-none"
        style={{
          opacity: hoveredTile ? 1 : 0,
          transitionDuration: "0.9s",
          transitionTimingFunction: cubicBezier("heavy"),
        }}
      >
        {meta ? (renderMeta ? renderMeta(meta) : meta.label) : null}
      </div>
    </div>
  );
}
gallery-barrel.ts
/** Anything with a stable key and an intrinsic aspect ratio (width / height). */
export type BarrelTile = {
  key: string;
  aspect: number;
};

export type BarrelLayoutItem<T extends BarrelTile = BarrelTile> = {
  key: string;
  tile: T;
  x: number;
  y: number;
  w: number;
  h: number;
  /** Vertical wrap period of this item's column (content + one gap). */
  periodY: number;
};

export type BarrelWorld<T extends BarrelTile = BarrelTile> = {
  items: BarrelLayoutItem<T>[];
  /** Horizontal wrap period — includes the seam gap after the last column. */
  width: number;
  /** Smallest column period; sizes the vertical copy pool. */
  minPeriodY: number;
};

export type DomeOptics = {
  /** Crest height in px — translateZ at the viewport center. */
  bulge: number;
  /** Dome width as a fraction of the smaller viewport dimension. */
  spread: number;
  /** Rotation gain — 1 keeps tiles tangent to the dome surface. */
  curve: number;
  /** Corner radius at the crest, in px. */
  round: number;
};

export type ProjectedTile = {
  x: number;
  y: number;
  z: number;
  rotateX: number;
  rotateY: number;
  radius: number;
};

export function wrapCoord(n: number, size: number): number {
  if (size <= 0) return 0;
  return ((n % size) + size) % size;
}

/**
 * Masonry pack built for seamless wrapping: the horizontal period is
 * `cols * (width + gap)` so the seam between copies is exactly one gap, and
 * each column wraps at its own period (`stacked height + one gap`) so the
 * vertical seam is also exactly one gap in every column. Per-column periods
 * differ slightly, which drifts the columns against each other and keeps the
 * repetition from reading as a block.
 */
export function layoutGalleryBarrel<T extends BarrelTile>(
  tiles: T[],
  columns: number,
  tileWidth: number,
  gap: number,
): BarrelWorld<T> {
  const cols = Math.max(1, Math.floor(columns));
  const width = Math.max(8, tileWidth);
  const gutter = Math.max(0, gap);
  const colH = Array.from({ length: cols }, () => 0);
  const colOf: number[] = [];
  const items: BarrelLayoutItem<T>[] = [];

  for (const tile of tiles) {
    const aspect = tile.aspect > 0 ? tile.aspect : 1;
    const col = colH.indexOf(Math.min(...colH));
    const h = width / aspect;
    colOf.push(col);
    items.push({
      key: tile.key,
      tile,
      x: col * (width + gutter),
      y: colH[col],
      w: width,
      h,
      periodY: 1,
    });
    // Trailing gutter doubles as the wrap-seam gap, making colH the period.
    colH[col] += h + gutter;
  }

  let minPeriodY = Infinity;
  items.forEach((item, i) => {
    item.periodY = Math.max(1, colH[colOf[i]]);
    if (item.periodY < minPeriodY) minPeriodY = item.periodY;
  });

  return {
    items,
    width: cols * (width + gutter),
    minPeriodY: Number.isFinite(minPeriodY) ? minPeriodY : 1,
  };
}

/**
 * How many toroidal copies are needed to cover the viewport in each axis.
 * Copy `c` renders at wrapped position + (c - 1) * period, so with sx0 in
 * [0, P) the copies span [-P, P * (copies - 1)). Vertical count is sized for
 * the shortest column period; longer columns just cull their spare copies.
 */
export function poolCopies(
  world: BarrelWorld,
  viewW: number,
  viewH: number,
): { x: number; y: number } {
  return {
    x: world.width > 0 ? Math.ceil(Math.max(1, viewW) / world.width) + 1 : 1,
    y:
      world.minPeriodY > 0
        ? Math.ceil(Math.max(1, viewH) / world.minPeriodY) + 1
        : 1,
  };
}

const DEG = 180 / Math.PI;
const MAX_TILT = 58;

function clampTilt(deg: number): number {
  return Math.max(-MAX_TILT, Math.min(MAX_TILT, deg));
}

/**
 * Place a tile tangent on a Gaussian dome bulging toward the camera at the
 * viewport center. z is the dome height at the tile's midpoint and the
 * rotations follow the analytic surface gradient, so tiles genuinely roll
 * onto the curved surface as they approach the crest (and the perspective
 * projection magnifies them there). Corner radius rises with dome height.
 */
export function projectDomeTile(
  sx: number,
  sy: number,
  tileW: number,
  tileH: number,
  viewW: number,
  viewH: number,
  optics: DomeOptics,
  flatten: boolean,
): ProjectedTile {
  if (flatten) {
    return { x: sx, y: sy, z: 0, rotateX: 0, rotateY: 0, radius: 0 };
  }

  const u = sx + tileW / 2 - viewW / 2;
  const v = sy + tileH / 2 - viewH / 2;
  const sigma = Math.max(1, Math.min(viewW, viewH) * optics.spread);
  const s2 = sigma * sigma;
  const g = Math.exp(-(u * u + v * v) / (2 * s2));
  // d(bulge * g)/du = -(bulge / s2) * u * g — the tangent slope of the dome.
  const slope = (optics.bulge / s2) * g;

  return {
    x: sx,
    y: sy,
    z: optics.bulge * g,
    // Tile below center: bottom edge falls away -> negative rotateX.
    rotateX: clampTilt(-Math.atan(slope * v) * DEG * optics.curve),
    // Tile right of center: right edge falls away -> positive rotateY.
    rotateY: clampTilt(Math.atan(slope * u) * DEG * optics.curve),
    radius: optics.round * g,
  };
}
The center is always the biggest frame. A throw only blurs while the wall is moving, so a still frame is always sharp.

The first version blinked. Tiles were added as they slid into view and removed as they left, and every image decoded again each time. Now every tile is placed once and a single loop only moves them, so a photograph never reloads while you pan.

The wall repeats in both directions, and each seam is exactly one gap wide. Columns repeat at slightly different heights, so they drift against each other and the loop never reads as a block. Under reduced motion the dome goes flat and stops sliding.

The live wall, with Daniel's photographs instead of placeholders.

Recede the rest

The work page is one row per project, each a strip of thumbnails. Every row is full-width photography, so a highlight would land on top of someone's picture. Instead, the row you point at stays exactly as it is and everything else steps back.

Show code
"use client";

import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { useScrollFade, SCROLL_FADE_CLASS } from "@/hooks/use-scroll-fade";

/** Pointer must rest on a row this long before the dim and title swap fire. */
const HOVER_HOLD_MS = 450;
const HEAVY = "cubic-bezier(0.76, 0, 0.24, 1)";

export type WorkIndexThumb = { key: string; aspect: number };

export type WorkIndexItem = {
  id: string;
  /** Accessible name of the row, and the heading while the row has focus. */
  title: string;
  thumbs: WorkIndexThumb[];
};

export type WorkIndexProps<T extends WorkIndexItem> = {
  items: T[];
  /** Standing headline; it swaps to the focused row's title. */
  heading?: string;
  renderThumb: (thumb: WorkIndexThumb, item: T) => ReactNode;
  /** Caption under each strip (client, title, category…). */
  renderCaption?: (item: T) => ReactNode;
  /** What the heading shows for a focused row. Defaults to item.title. */
  renderTitle?: (item: T) => ReactNode;
  /** Makes rows links. */
  href?: (item: T) => string;
  /** Start with this row focused (static previews); the pointer takes over after. */
  initialFocusedId?: string;
  className?: string;
};

/** Drop transient hover once the page scrolls: the pointer has left it behind. */
function useDismissOnScroll(dismiss: () => void) {
  const dismissRef = useRef(dismiss);
  useEffect(() => {
    dismissRef.current = dismiss;
  });
  useEffect(() => {
    let frame = 0;
    const onScroll = () => {
      if (frame) return;
      frame = window.requestAnimationFrame(() => {
        frame = 0;
        dismissRef.current();
      });
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      window.removeEventListener("scroll", onScroll);
      if (frame) window.cancelAnimationFrame(frame);
    };
  }, []);
}

function Strip<T extends WorkIndexItem>({
  item,
  renderThumb,
}: {
  item: T;
  renderThumb: WorkIndexProps<T>["renderThumb"];
}) {
  const ref = useScrollFade<HTMLDivElement>();
  return (
    <div
      ref={ref}
      className={cn(
        "-mx-3 h-[var(--work-thumb-height)] overflow-x-auto overflow-y-clip overscroll-x-contain [scrollbar-width:none]",
        SCROLL_FADE_CLASS,
      )}
    >
      <div className="flex h-full w-max gap-1 px-3">
        {item.thumbs.map((thumb) => (
          <div key={thumb.key} className="relative h-full shrink-0" style={{ aspectRatio: thumb.aspect }}>
            {renderThumb(thumb, item)}
          </div>
        ))}
      </div>
    </div>
  );
}

/**
 * A project index of thumbnail strips. Resting on a row (450ms) or focusing it
 * recedes every other row to 18% and cross-fades the page heading to that
 * row's title through a 4px blur, so the pointed-at project reads on its own.
 */
export function WorkIndex<T extends WorkIndexItem>({
  items,
  heading = "Work",
  renderThumb,
  renderCaption,
  renderTitle,
  href,
  initialFocusedId,
  className,
}: WorkIndexProps<T>) {
  const [hoveredId, setHoveredId] = useState<string | null>(initialFocusedId ?? null);
  const hoverTimer = useRef<number | null>(null);

  const clearHoverTimer = () => {
    if (hoverTimer.current !== null) {
      window.clearTimeout(hoverTimer.current);
      hoverTimer.current = null;
    }
  };

  // Keyboard focus commits at once; the pointer has to rest first.
  const commitHover = (id: string | null) => {
    clearHoverTimer();
    setHoveredId(id);
  };

  const requestHover = (id: string | null) => {
    clearHoverTimer();
    if (id === null) {
      setHoveredId(null);
      return;
    }
    hoverTimer.current = window.setTimeout(() => {
      setHoveredId(id);
      hoverTimer.current = null;
    }, HOVER_HOLD_MS);
  };

  useDismissOnScroll(() => commitHover(null));
  useEffect(() => clearHoverTimer, []);

  const hovered = items.find((item) => item.id === hoveredId) ?? null;
  // Held through the fade-out so the heading never blanks mid-transition.
  const [lastHovered, setLastHovered] = useState<T | null>(null);
  if (hovered && hovered !== lastHovered) setLastHovered(hovered);

  const lineClass = cn(
    "col-start-1 row-start-1 m-0 truncate text-[20px] font-semibold leading-6 tracking-[-0.025em] text-foreground",
    "transition-[opacity,filter] duration-[900ms] motion-reduce:transition-none",
    "data-[on=false]:pointer-events-none data-[on=false]:opacity-0 data-[on=false]:blur-[4px]",
  );

  return (
    <div className={cn("@container [--work-thumb-height:clamp(120px,22cqw,160px)]", className)}>
      <header className="grid pb-3">
        <h2 className={lineClass} data-on={!hovered} style={{ transitionTimingFunction: HEAVY }}>
          {heading}
        </h2>
        <span aria-hidden className={lineClass} data-on={Boolean(hovered)} style={{ transitionTimingFunction: HEAVY }}>
          {lastHovered ? (renderTitle ? renderTitle(lastHovered) : lastHovered.title) : null}
        </span>
      </header>

      <ol className="m-0 flex list-none flex-col gap-12 p-0" data-hovering={Boolean(hovered)}>
        {items.map((item) => {
          const Row = href ? "a" : "div";
          return (
            <li
              key={item.id}
              data-hovered={item.id === hoveredId}
              className="group/row min-w-0"
              onPointerEnter={(event) => {
                if (event.pointerType !== "touch") requestHover(item.id);
              }}
              onPointerLeave={() => requestHover(null)}
            >
              <Row
                {...(href ? { href: href(item) } : { tabIndex: 0, role: "group" })}
                aria-label={item.title}
                onFocus={() => commitHover(item.id)}
                onBlur={() => commitHover(null)}
                className={cn(
                  "block min-w-0 rounded-sm text-inherit no-underline outline-none focus-visible:ring-2 focus-visible:ring-ring",
                  "transition-opacity duration-[900ms] motion-reduce:transition-none",
                  hovered && item.id !== hoveredId && "opacity-[0.18]",
                )}
                style={{ transitionTimingFunction: HEAVY }}
              >
                <Strip item={item} renderThumb={renderThumb} />
                {renderCaption ? <div className="pt-1">{renderCaption(item)}</div> : null}
              </Row>
            </li>
          );
        })}
      </ol>
    </div>
  );
}
Nothing about the focused row changes. The rest of the page gets quieter around it.
At rest, every project has the same weight.
Pointed at, the other rows fall to 18% and the heading becomes the project's name.

The pointer has to rest on a row for 450ms before anything changes, so moving across the page to the menu doesn't flicker the list. The heading keeps the last name while it fades back to "Work", so it never goes blank, and scrolling clears it.

Proof

daniel-derro.com is live with all twenty-three projects, built on Next.js with Sanity behind it. The version running now is commit eff96ba, and both demos on this page are that code, with the photographs swapped for placeholders.

The same release made the whole site readable as plain text at /llms.md, so anyone, or any AI assistant, can read the archive without loading a single image.

The cost is real. The wall keeps more on the page than you can see, so it can move without loading anything. The dome is tuned for a desktop screen; on a phone it drops to four columns. And the focus fade never reaches touch at all.

Info stays plain text over the same film: about, services, clients and contact, nothing else.

Learnings

Move the work, never mark it

On a photographer's site, the interface earns its place by moving things: the wall slides, the dome brings one frame closer, the other rows step back. Nothing gets drawn on a photograph: no border, no tint, no highlight.

I tuned the dome by hand, with live sliders on the page instead of numbers in code, and kept the values where they landed.

The dome is a desktop idea. On a phone it's just a smaller wall, and I haven't found its phone version yet.

Components

shadcn CLI
npx shadcn@latest add \
  https://benjaminuribe.com/r/dome-gallery.json \
  https://benjaminuribe.com/r/work-index.json