A portfolio you wander, for a portrait photographer in New York

Most portfolios make you pick a project before you've seen a photo. Nuvany's opens on all of them at once.

WhenJan –– May '26
ForNuvany David
DisciplineDesign engineering, front-end, photography portfolio
ToolsNext.js, React, Sanity, Framer Motion, Lenis, CSS Modules

Problem

Nuvany David is a portrait photographer in New York, Bronx-born and raised in Jersey City. Her work is intimate environmental portraiture, made across commissions and personal series.

A portfolio grid makes you choose a project by its title or its cover before you've seen any of the work. For a photographer whose subjects are people, that puts a menu between the visitor and the faces.

The site's job was to put the photographs first and the menu second.

Thinking

The first version, in January, followed the usual pattern: a home page that switched between a grid and a drag view, an intro animation, an info overlay, and project pages that scrolled sideways. By April the index was a dark grid of covers you could filter. Over the next four months most of it went, one piece at a time.

Paths not taken

April: a black index where one cover had to stand for each series.

What stayed is one rule: nothing on screen that isn't a photograph or a way to reach one. The site name appears only in the nav, never on the canvas, and the nav has no background, so it never boxes off part of a photo.

One wall

The home page is a single wall of photographs that repeats forever in every direction. You drag, flick or scroll, and the wall moves with you, with no edge to reach and no order to follow. Every project is on it, up to ten photos each.

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,
  };
}
Daniel Derro's version of the same wall, on placeholders. His bulges toward you like a lens; Nuvany's lies flat and flush, but the wrap, the throw and the arrow keys work the same way.
The live wall: every project at once, flush and in full colour, cut off at every edge because it keeps going.

The wall is one rectangle of photos, built at least 1.2 times the size of the screen and repeated like a tile. Each row is laid out at the photos' natural shapes, then stretched to the width of the widest row, so the edges meet without a seam.

The turn came in the last week of work. The wall's glide was tuned as a fixed fraction per frame, so on a 120Hz screen it settled twice as fast as on a 60Hz one. In May it moved to time constants: 320ms for the wheel, with a flick decaying by the second, not by the frame. Now it feels the same on any display.

The index

The index is the same work as a list, for visitors who want to look something up. Each row has a number, a title, commercial or personal, and a strip of that project's photos. It replaced a grid of single covers, where one frame had to speak for a series.

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>
  );
}
Daniel Derro's index: the same rows of photo strips, on placeholders. His dims the other rows when you rest on one; Nuvany's keeps every row at full weight.
The live index. Strips end on the last photo that fits whole, so some rows are short.

A strip clipped at the edge of its column ends on a sliver of the last photo, which on a photographer's site reads as a mistake. So each strip measures its own width and shows only the photos that fit whole.

Outcome

The site is live at nuvanydavid.com: the wall, the index, project pages that snap one photo per screen, and an about page. Projects are published from Sanity, and the site picks up a change within a minute, with no deploy.

A project page: one photo per screen, with the series facts at the bottom edge.
The about page sets the bio in the same type as the nav.

The wall has a cost. To keep travel smooth it holds up to 800 photos in the page at once, and the live home page reaches that cap. Every page change also waits 450ms for the old view to fade out before the next one loads.

Learnings

Tune motion in seconds, not frames.

A smoothing step written per frame runs twice as fast on a 120Hz screen. Writing it as a time constant, 1 − e^(−dt/τ), gives the same feel at any refresh rate and after a dropped frame.

Components

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