← All components

Gallery

A masonry wall and a camera roll, with the photo flying between them.

The idea

What makes it one component rather than two is the flight. Open a tile and that photo travels from its place on the wall to the roll's stage; leave the roll and it travels back to its tile, which the wall has already scrolled into view. It is one copy of the photo on a compositor transform, so the trip keeps its own time however busy the main thread is swapping layouts underneath. In the roll the strip slides so the open frame sits dead centre, a fixed playhead the roll passes under.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Gallery — usage18 lines
import { useState } from "react";
import { Gallery, GalleryLayoutToggle } from "mihirmodi-ui";
// Optional: a GPU dissolve between frames. Needs `npm i ogl`.
import { createPhotoStage } from "mihirmodi-ui/photo-stage";

const [layout, setLayout] = useState<"grid" | "roll">("grid");

// A gallery that is the page: the roll is one viewport tall, keys on the window.
<Gallery
  items={photos}                  // { id, src, ratio }[], ratio = width / height
  layout={layout}
  onLayoutChange={setLayout}
  stage={createPhotoStage}
  keys="window"
/>
<div className="fixed bottom-5 left-1/2 -translate-x-1/2">
  <GalleryLayoutToggle value={layout} onChange={setLayout} />
</div>

Props

PropNotes
items{ id, src, ratio, alt? }[]`ratio` is width over height. Tiles reserve it, so nothing jumps as images load, and the strip never crops.
layout / onLayoutChange"grid" | "roll"Controlled. Put a `GalleryLayoutToggle` wherever the switch belongs.
heightnumber | stringGive it a box: the wall scrolls inside it and the roll fills it. Leave it out for a gallery that is the page.
stagePhotoStageFactory`createPhotoStage` from `mihirmodi-ui/photo-stage`, for a GPU noise dissolve between frames. Needs `ogl`; without it the roll crossfades.
keys"focus" | "window"Where the roll hears arrows, Home, End and Escape. `window` for a gallery that is the page.
columnsClassNamestringThe wall's columns.
onStep(index) => voidEvery step through the roll — a sound, an analytics ping.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/media/Gallery.tsx566 lines
"use client";

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
import { AnimatePresence, LazyMotion, animate, domAnimation, m, useReducedMotion } from "motion/react";
import { containRect, rectOf, type Rect } from "../../lib/rect.js";
import type { PhotoStage } from "../../lib/photoStage.js";

export type GalleryItem = { id: string; src: string; /** width ÷ height */ ratio: number; alt?: string };
export type GalleryLayout = "grid" | "roll";
export type PhotoStageFactory = (canvas: HTMLCanvasElement) => PhotoStage | null;

export type GalleryProps = {
  items: GalleryItem[];
  layout: GalleryLayout;
  onLayoutChange: (layout: GalleryLayout) => void;
  /**
   * Give the gallery a box. The wall scrolls inside it and the roll fills it.
   * Leave it out for a gallery that is the page: the wall runs with the page
   * and the roll is one viewport tall.
   */
  height?: number | string;
  /** The wall's columns, as utilities. */
  columnsClassName?: string;
  /**
   * The GPU layer from `mihirmodi-ui/photo-stage`, for a noise dissolve between
   * frames. Without it the roll crossfades, and nothing loads WebGL.
   */
  stage?: PhotoStageFactory;
  /** Where the roll's keys are heard. `window` for a gallery that is the page; `focus` inside one. */
  keys?: "focus" | "window";
  /** Called on every step through the roll — a sound, an analytics ping. */
  onStep?: (index: number) => void;
  emptyText?: string;
  className?: string;
};

/* The photo's flight between layouts: inside the overlay tier, since the photo is
   on screen at both ends and only has to be followable. Decisive, not springy. */
const FLIGHT_MS = 420;
const FLIGHT_EASE = "cubic-bezier(0.25, 0.46, 0.45, 0.94)";
/* The two layouts crossfade under the flight. */
const SWAP = 0.3;
/* The GPU dissolve between frames: the subject of the moment, so it takes longer. */
const DISSOLVE = 0.62;

/* Strip geometry. Every thumb is the same height and takes its width from its own
   ratio — nothing is cropped, because at this size the shape is most of what tells
   one frame from the next. */
const THUMB = 48;
const GAP = 4;
const STRIP = { type: "spring", stiffness: 260, damping: 32, mass: 0.9 } as const;
const EDGE_FADE = "linear-gradient(to right, transparent 0%, #000 9%, #000 91%, transparent 100%)";
const SWIPE = 44;

type Flight = { src: string; drawAt: Rect; first: string; last: string };

/**
 * A photo collection in two layouts: a masonry wall, and a camera roll — one photo
 * open above a strip of every frame.
 *
 * The part that makes it one component rather than two is the flight. Open a tile
 * and that photo travels from its place on the wall to the roll's stage; leave the
 * roll and it travels back to its tile, which the wall has already scrolled into
 * view. It is one copy of the photo on a compositor transform — drawn once at the
 * larger end and scaled toward the smaller — so the trip keeps its own time however
 * busy the main thread is with swapping the layouts underneath, which crossfade.
 * Toggling without picking a tile flies the frame nearest the middle of what you
 * were looking at, so the roll carries on from where you were.
 *
 * In the roll, the strip slides so the open frame is always dead centre — a fixed
 * playhead the roll passes under, rather than a highlight to hunt for. Arrow keys,
 * a click on a thumb, a horizontal trackpad swipe or a finger swipe across the
 * photo all step; Escape goes back to the wall. Frames crossfade in place, or, with
 * the optional `stage`, dissolve through a noise threshold on the GPU.
 *
 * You own `layout`. Put a `GalleryLayoutToggle` wherever the control belongs.
 */
export function Gallery({
  items,
  layout,
  onLayoutChange,
  height,
  columnsClassName = "columns-3 sm:columns-4",
  stage,
  keys = "focus",
  onStep,
  emptyText = "Nothing here yet.",
  className = "",
}: GalleryProps) {
  const reduce = useReducedMotion();
  const root = useRef<HTMLDivElement>(null);
  const wall = useRef<HTMLDivElement>(null);
  const photoBox = useRef<HTMLDivElement>(null);
  const flyer = useRef<HTMLImageElement>(null);
  const [shown, setShown] = useState<GalleryLayout>(layout);
  const [index, setIndex] = useState(() => Math.floor((items.length - 1) / 2));
  const [flight, setFlight] = useState<Flight | null>(null);
  /** Set by a tile click, which has already said which photo it wants. */
  const openTile = useRef<string | null>(null);
  /** Where the photo takes off from, measured while the outgoing layout is still on screen. */
  const takeoff = useRef<{ from: Rect; to: GalleryLayout } | null>(null);

  const item = items[Math.min(index, items.length - 1)];
  const boxed = height !== undefined;

  const tileRect = useCallback((id: string): Rect | null => {
    const el = root.current?.querySelector<HTMLImageElement>(`img[data-frame="${CSS.escape(id)}"]`);
    const r = el?.getBoundingClientRect();
    return r && r.width > 0 && r.height > 0 ? rectOf(r) : null;
  }, []);

  /** Of the tiles on screen, the one nearest the middle of what you are looking at. */
  const centreTile = useCallback(() => {
    const view = boxed && wall.current ? wall.current.getBoundingClientRect() : new DOMRect(0, 0, window.innerWidth, window.innerHeight);
    const mx = view.x + view.width / 2;
    const my = view.y + view.height / 2;
    let best: { id: string; rect: Rect; d: number } | null = null;
    for (const el of root.current?.querySelectorAll<HTMLImageElement>("img[data-frame]") ?? []) {
      const r = el.getBoundingClientRect();
      if (!r.width || r.bottom <= view.top || r.top >= view.bottom) continue;
      const d = Math.hypot(r.x + r.width / 2 - mx, r.y + r.height / 2 - my);
      if (!best || d < best.d) best = { id: el.dataset.frame!, rect: rectOf(r), d };
    }
    return best;
  }, [boxed]);

  const photoRect = useCallback(() => {
    const box = photoBox.current?.getBoundingClientRect();
    return box && item ? containRect(rectOf(box), item.ratio) : null;
  }, [item]);

  // The layout prop changed: measure the takeoff from the layout still rendered,
  // then swap. A layout effect, so both happen before anything is painted.
  useLayoutEffect(() => {
    if (layout === shown) return;
    let from: Rect | null = null;
    if (layout === "roll") {
      if (openTile.current) from = tileRect(openTile.current);
      else {
        const c = centreTile();
        if (c) {
          const i = items.findIndex((it) => it.id === c.id);
          if (i >= 0) setIndex(i);
          from = c.rect;
        }
      }
    } else from = photoRect();
    openTile.current = null;
    takeoff.current = from ? { from, to: layout } : null;
    setShown(layout);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [layout]);

  // The incoming layout is in the DOM: measure the landing and launch.
  useLayoutEffect(() => {
    const t = takeoff.current;
    takeoff.current = null;
    if (!t || t.to !== shown || reduce || !item) return;
    let to: Rect | null;
    if (shown === "roll") to = photoRect();
    else {
      const tile = root.current?.querySelector(`[data-tile="${CSS.escape(item.id)}"]`);
      tile?.scrollIntoView({ block: "center", behavior: "instant" as ScrollBehavior });
      to = tileRect(item.id);
    }
    if (!to) return;
    // Drawn once at the larger end; the smaller end is a uniform scale of it,
    // which is exact because both ends are the photo's own aspect ratio.
    const big = t.from.width >= to.width ? t.from : to;
    const small = big === t.from ? to : t.from;
    const k = small.width / big.width;
    const atBig = `translate(${big.x}px, ${big.y}px)`;
    const atSmall = `translate(${small.x}px, ${small.y}px) scale(${k})`;
    setFlight({ src: item.src, drawAt: big, first: big === t.from ? atBig : atSmall, last: big === to ? atBig : atSmall });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [shown]);

  useLayoutEffect(() => {
    const el = flyer.current;
    if (!flight || !el) return;
    const anim = el.animate([{ transform: flight.first }, { transform: flight.last }], { duration: FLIGHT_MS, easing: FLIGHT_EASE, fill: "both" });
    anim.onfinish = () => setFlight(null);
    return () => anim.cancel();
  }, [flight]);

  const flyingId = flight ? item?.id : null;

  if (items.length === 0 || !item) {
    return (
      <div className={`flex items-center justify-center bg-gray-100 ${className}`} style={{ height: height ?? "60vh" }}>
        <p className="text-[14px] text-muted">{emptyText}</p>
      </div>
    );
  }

  return (
    <div ref={root} className={`relative bg-gray-100 ${className}`} style={boxed ? { height } : undefined}>
      {flight && (
        <img
          ref={flyer}
          src={flight.src}
          alt=""
          aria-hidden
          className="pointer-events-none fixed left-0 top-0 z-[45] origin-top-left object-cover"
          style={{ width: flight.drawAt.width, height: flight.drawAt.height, transform: flight.first }}
        />
      )}
      <LazyMotion features={domAnimation} strict>
        <m.div key={shown} className={boxed ? "h-full" : ""} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: reduce ? 0 : SWAP, ease: "easeOut" }}>
          {shown === "roll" ? (
            <Roll
              items={items}
              index={index}
              onIndex={(i) => {
                setIndex(i);
                onStep?.(i);
              }}
              onExit={() => onLayoutChange("grid")}
              photoBox={photoBox}
              hidePhoto={!!flight}
              boxed={boxed}
              stage={stage}
              keys={keys}
              photoRect={photoRect}
            />
          ) : (
            <div ref={wall} className={`p-1 ${boxed ? "h-full overflow-y-auto" : "min-h-[60vh]"}`}>
              <div className={`${columnsClassName} gap-1`}>
                {items.map((d, i) => (
                  <figure key={d.id} data-tile={d.id} className="mb-1 break-inside-avoid" style={{ aspectRatio: String(d.ratio) }}>
                    <button
                      type="button"
                      aria-label={d.alt ? `Open ${d.alt}` : `Open photo ${i + 1} of ${items.length}`}
                      onClick={() => {
                        openTile.current = d.id;
                        setIndex(i);
                        onLayoutChange("roll");
                      }}
                      className="group block h-full w-full cursor-zoom-in overflow-hidden"
                    >
                      <img
                        src={d.src}
                        alt=""
                        data-frame={d.id}
                        loading="lazy"
                        decoding="async"
                        // One copy of the photo on screen at a time: the tile it is
                        // landing on waits, invisible but measurable.
                        style={flyingId === d.id ? { opacity: 0 } : undefined}
                        className="block h-full w-full object-cover transition-transform duration-300 ease-geist group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
                      />
                    </button>
                  </figure>
                ))}
              </div>
            </div>
          )}
        </m.div>
      </LazyMotion>
    </div>
  );
}

function Roll({
  items,
  index,
  onIndex,
  onExit,
  photoBox,
  hidePhoto,
  boxed,
  stage,
  keys,
  photoRect,
}: {
  items: GalleryItem[];
  index: number;
  onIndex: (i: number) => void;
  onExit: () => void;
  photoBox: RefObject<HTMLDivElement | null>;
  hidePhoto: boolean;
  boxed: boolean;
  stage?: PhotoStageFactory;
  keys: "focus" | "window";
  photoRect: () => Rect | null;
}) {
  const reduce = useReducedMotion();
  const self = useRef<HTMLDivElement>(null);
  const stageEl = useRef<HTMLDivElement>(null);
  const wheel = useRef(0);
  const touch = useRef<{ x: number; y: number } | null>(null);
  const item = items[Math.min(index, items.length - 1)];
  const gl = usePhotoStage(stage && !reduce ? stage : undefined, item.src, hidePhoto, photoRect, [items[index - 1]?.src, items[index + 1]?.src].filter(Boolean) as string[]);

  const { widths, centres } = useMemo(() => {
    const widths: number[] = [];
    const centres: number[] = [];
    let x = 0;
    for (const f of items) {
      const w = Math.round(THUMB * f.ratio);
      widths.push(w);
      centres.push(x + w / 2);
      x += w + GAP;
    }
    return { widths, centres };
  }, [items]);
  const offset = -(centres[Math.min(index, centres.length - 1)] ?? 0);

  // Readable synchronously, so two steps in one tick build on each other.
  const cursor = useRef(index);
  cursor.current = index;
  const go = useCallback(
    (target: number) => {
      const next = Math.max(0, Math.min(items.length - 1, target));
      if (next === cursor.current) return;
      cursor.current = next;
      onIndex(next);
    },
    [items.length, onIndex]
  );
  const step = useCallback((d: number) => go(cursor.current + d), [go]);

  const onKey = useCallback(
    (e: KeyboardEvent | React.KeyboardEvent) => {
      if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
      if (e.key === "ArrowRight" || e.key === "ArrowDown") step(1);
      else if (e.key === "ArrowLeft" || e.key === "ArrowUp") step(-1);
      else if (e.key === "Home") go(0);
      else if (e.key === "End") go(items.length - 1);
      else if (e.key === "Escape") onExit();
      else return;
      e.preventDefault();
    },
    [go, step, items.length, onExit]
  );

  useEffect(() => {
    if (keys !== "window") return;
    const h = (e: KeyboardEvent) => {
      if (document.activeElement?.closest("nav, input, textarea")) return;
      onKey(e);
    };
    window.addEventListener("keydown", h);
    return () => window.removeEventListener("keydown", h);
  }, [keys, onKey]);

  // Focus the roll when it opens inside a page, so its keys work at once.
  useEffect(() => {
    if (keys === "focus") self.current?.focus({ preventScroll: true });
  }, [keys]);

  // A horizontal trackpad swipe steps; a vertical wheel is left alone.
  useEffect(() => {
    const el = stageEl.current;
    if (!el) return;
    const onWheel = (e: WheelEvent) => {
      if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
      e.preventDefault();
      wheel.current += e.deltaX;
      if (Math.abs(wheel.current) < 60) return;
      step(wheel.current > 0 ? 1 : -1);
      wheel.current = 0;
    };
    el.addEventListener("wheel", onWheel, { passive: false });
    return () => el.removeEventListener("wheel", onWheel);
  }, [step]);

  return (
    <div
      ref={self}
      tabIndex={-1}
      onKeyDown={keys === "focus" ? onKey : undefined}
      className={`flex flex-col overflow-hidden outline-none ${boxed ? "h-full" : "h-[100dvh]"}`}
    >
      {gl.canvas}
      <div
        ref={stageEl}
        className="relative min-h-0 flex-1"
        onPointerDown={(e) => {
          if (e.pointerType !== "mouse") touch.current = { x: e.clientX, y: e.clientY };
        }}
        onPointerUp={(e) => {
          const s = touch.current;
          touch.current = null;
          if (!s) return;
          const dx = e.clientX - s.x;
          if (Math.abs(dx) >= SWIPE && Math.abs(dx) >= Math.abs(e.clientY - s.y)) step(dx < 0 ? 1 : -1);
        }}
        onPointerCancel={() => (touch.current = null)}
      >
        <div ref={photoBox} className="absolute inset-4 sm:inset-6">
          {!gl.active && (
            <AnimatePresence initial={false}>
              <m.img
                key={item.id}
                src={item.src}
                alt={item.alt ?? ""}
                draggable={false}
                decoding="async"
                style={{ aspectRatio: String(item.ratio), visibility: hidePhoto ? "hidden" : undefined }}
                className="absolute inset-0 m-auto max-h-full max-w-full select-none object-contain"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                // The incoming frame takes a touch longer than the outgoing one,
                // so the pair never dips to a gap of grey between them.
                exit={{ opacity: 0, transition: reduce ? { duration: 0 } : { duration: 0.22, ease: "easeOut" } }}
                transition={reduce ? { duration: 0 } : { duration: 0.28, ease: "easeOut" }}
              />
            </AnimatePresence>
          )}
        </div>
      </div>

      {/* The count answers "where am I" across a hundred frames; the strip only gives a feel for it. */}
      <div aria-hidden className="shrink-0 px-4 pb-2 text-right text-[12px] leading-none tabular-nums text-faint sm:px-6">
        <span className="text-gray-1000">{index + 1}</span> / {items.length}
      </div>
      <div role="group" aria-label="Camera roll" className="relative h-[76px] shrink-0 overflow-hidden" style={{ maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE }}>
        <m.div
          className="absolute left-1/2 top-0 flex h-full items-center"
          style={{ gap: GAP }}
          initial={{ x: offset }}
          animate={{ x: offset }}
          transition={reduce ? { duration: 0 } : STRIP}
        >
          {items.map((f, i) => (
            <button
              key={f.id}
              type="button"
              aria-label={`Photo ${i + 1} of ${items.length}`}
              aria-current={i === index ? "true" : undefined}
              tabIndex={i === index ? 0 : -1}
              onClick={() => go(i)}
              style={{ width: widths[i], height: THUMB }}
              className={`relative shrink-0 overflow-hidden transition-[opacity,transform] duration-200 ease-geist motion-reduce:transition-none ${
                i === index ? "z-10 scale-[1.22] opacity-100" : "opacity-55 hover:opacity-90"
              }`}
            >
              <img src={f.src} alt="" loading="lazy" decoding="async" className="h-full w-full object-cover" />
            </button>
          ))}
        </m.div>
      </div>
    </div>
  );
}

/**
 * The optional GPU layer: one viewport-fixed canvas that draws the open photo into
 * its box and dissolves between frames. `active` stays false until a context and
 * the first texture exist, so the `<img>` holds the frame until there is something
 * to replace it — and for good, where WebGL is missing or the context is lost.
 */
function usePhotoStage(
  factory: PhotoStageFactory | undefined,
  src: string,
  hidden: boolean,
  photoRect: () => Rect | null,
  warm: string[]
) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const stage = useRef<PhotoStage | null>(null);
  const shown = useRef<string | null>(null);
  const [active, setActive] = useState(false);
  const rectRef = useRef(photoRect);
  rectRef.current = photoRect;
  const warmRef = useRef(warm);
  warmRef.current = warm;
  const hiddenRef = useRef(hidden);
  hiddenRef.current = hidden;

  const place = useCallback(() => {
    stage.current?.setRect(hiddenRef.current ? null : rectRef.current());
  }, []);

  useEffect(() => {
    const el = canvasRef.current;
    if (!factory || !el) return;
    const s = factory(el);
    if (!s) return;
    stage.current = s;
    let cancelled = false;
    s.resize();
    s.show(src)
      .then(() => {
        if (cancelled) return;
        shown.current = src;
        place();
        setActive(true);
        s.warm(warmRef.current);
      })
      .catch(() => setActive(false));
    // The canvas is fixed to the viewport; the photo's box is not, so anything
    // that moves the box moves the rect.
    let ticking = false;
    const onMove = () => {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(() => {
        ticking = false;
        s.resize();
        place();
      });
    };
    window.addEventListener("resize", onMove);
    window.addEventListener("scroll", onMove, true);
    const lost = (e: Event) => {
      e.preventDefault();
      setActive(false);
    };
    el.addEventListener("webglcontextlost", lost);
    return () => {
      cancelled = true;
      window.removeEventListener("resize", onMove);
      window.removeEventListener("scroll", onMove, true);
      el.removeEventListener("webglcontextlost", lost);
      s.dispose();
      stage.current = null;
      shown.current = null;
      setActive(false);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [factory]);

  // Hidden while the photo is in flight; placed again when it lands.
  useEffect(() => {
    if (active) place();
  }, [hidden, active, place]);

  // Dissolve to a new frame.
  useEffect(() => {
    const s = stage.current;
    if (!s || !active || shown.current === null || shown.current === src) return;
    let stop: (() => void) | undefined;
    let cancelled = false;
    s.prepare(src)
      .catch(() => null)
      .then(() => {
        if (cancelled) return;
        place();
        const c = animate(0, 1, {
          duration: DISSOLVE,
          ease: [0.25, 0.46, 0.45, 0.94],
          onUpdate: (p) => s.progress(p),
          onComplete: () => {
            s.settle();
            shown.current = src;
            s.warm(warmRef.current);
          },
        });
        stop = () => c.stop();
      });
    return () => {
      cancelled = true;
      stop?.();
      s.settle();
      shown.current = src;
    };
  }, [src, active, place]);

  const canvas = factory ? (
    <canvas ref={canvasRef} aria-hidden className={`pointer-events-none fixed left-0 top-0 z-40 ${active ? "opacity-100" : "opacity-0"}`} />
  ) : null;

  return { canvas, active };
}
packages/ui/src/components/media/GalleryLayoutToggle.tsx72 lines
"use client";

import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import type { GalleryLayout } from "./Gallery.js";
import { FLAT, SLIDE } from "../../lib/motion.js";
import { fillFade, glyphProps, solid } from "../../lib/glyph.js";

/**
 * The gallery's two layouts as a small glass switch. The chosen one is filled and
 * sits on the highlight, which slides between them — fill means chosen, here as
 * everywhere else in the package.
 */
export function GalleryLayoutToggle({ value, onChange, className = "" }: { value: GalleryLayout; onChange: (v: GalleryLayout) => void; className?: string }) {
  const reduce = useReducedMotion();
  return (
    <LazyMotion features={domAnimation} strict>
      <div role="radiogroup" aria-label="Layout" className={`glass-nav dock-shadow relative flex rounded-2xl border-[0.5px] border-gray-alpha-400 p-1 ${className}`}>
        <m.span
          aria-hidden
          className="absolute left-1 top-1 h-10 w-10 rounded-[12px] bg-highlight shadow-[0_2px_2px_rgba(0,0,0,0.04)]"
          initial={false}
          animate={{ x: value === "grid" ? 0 : 40 }}
          transition={reduce ? FLAT : SLIDE}
        />
        {(["grid", "roll"] as const).map((v) => (
          <button
            key={v}
            type="button"
            role="radio"
            aria-checked={value === v}
            aria-label={v === "grid" ? "Grid" : "Camera roll"}
            onClick={() => onChange(v)}
            className={`relative flex h-10 w-10 items-center justify-center transition-colors duration-150 ${value === v ? "text-gray-1000" : "text-quiet hover:text-gray-1000"}`}
          >
            {v === "grid" ? <GridGlyph filled={value === v} /> : <RollGlyph filled={value === v} />}
          </button>
        ))}
      </div>
    </LazyMotion>
  );
}

function GridGlyph({ filled }: { filled: boolean }) {
  const cells = [
    [4, 4, 7, 9],
    [13, 4, 7, 5.5],
    [4, 15, 7, 5],
    [13, 11.5, 7, 8.5],
  ];
  return (
    <svg {...glyphProps} className="h-[18px] w-[18px]">
      {cells.map(([x, y, w, h]) => (
        <rect key={`${x}${y}`} x={x} y={y} width={w} height={h} rx="1.6" />
      ))}
      <g className={fillFade(filled)}>
        {cells.map(([x, y, w, h]) => (
          <rect key={`f${x}${y}`} x={x} y={y} width={w} height={h} rx="1.6" {...solid} />
        ))}
      </g>
    </svg>
  );
}

function RollGlyph({ filled }: { filled: boolean }) {
  return (
    <svg {...glyphProps} className="h-[18px] w-[18px]">
      <rect x="4" y="4" width="16" height="11" rx="2" />
      <rect x="4" y="4" width="16" height="11" rx="2" {...solid} className={fillFade(filled)} />
      <path d="M4 19.5h3M10.5 19.5h3M17 19.5h3" />
    </svg>
  );
}
packages/ui/src/lib/photoStage.ts458 lines
/**
 * mihirmodi-ui/photo-stage — the optional GPU layer for `Gallery`.
 *
 * Its own entry point so that nothing in the main package imports `ogl`: install
 * `ogl` and pass `createPhotoStage` to `<Gallery stage={createPhotoStage} />`,
 * and the open photo dissolves between frames through a noise threshold on the
 * GPU. Leave it out and the gallery crossfades, with no WebGL anywhere.
 *
 * Ported from the portfolio's gallery, where the notes below were written.
 */
import { Mesh, Program, Renderer, Texture, Triangle } from "ogl";
import type { Rect } from "./rect.js";
export type { Rect } from "./rect.js";

/**
 * The gallery's photo layer: one viewport-sized canvas that draws a single photo
 * into an arbitrary screen rect, dissolving between frames on the GPU.
 *
 * It sits above the gallery rather than inside either layout, which is what lets
 * it carry the toggle: the same quad that rests in the roll's stage can start at a
 * wall tile's rect and travel there. None of that is layout — it's four numbers in
 * a uniform — so the trip costs no reflow at any size.
 *
 * The wall itself stays DOM. A hundred tiles as GPU textures is ~590MB before any
 * atlasing, and those tiles carry the keyboard and screen-reader semantics a canvas
 * can't. Only the open photo, which is decorative (`alt=""`, the strip holds the
 * labels), is drawn here.
 *
 * Returns null whenever a context can't be had, which is the caller's cue to keep
 * its <img>. Nothing animates on its own: callers drive `progress` and `setRect`,
 * so easing stays with the rest of the site's motion.
 */


/**
 * Textures held at once. Enough for the open photo, the one dissolving in, and a
 * couple of steps either side warmed ahead — and no more, because these are full
 * GPU allocations: a 1200x1200 photo is ~5.8MB, so keeping every frame you walked
 * past would reach the memory the wall was never allowed to spend.
 */
const CACHE_CAP = 8;

const VERTEX = /* glsl */ `
  attribute vec2 uv;
  attribute vec2 position;
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = vec4(position, 0.0, 1.0);
  }
`;

/**
 * A value-noise field, used as a per-pixel threshold so the dissolve breaks up
 * organically instead of wiping in a straight line. Cheap on purpose: two hashes
 * and a smoothstep, no texture lookup.
 */
const NOISE = /* glsl */ `
  float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
  float vnoise(vec2 p) {
    vec2 i = floor(p), f = fract(p);
    f = f * f * (3.0 - 2.0 * f);
    return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
               mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
  }
`;

/**
 * Placement is the whole geometry: the caller says where the photo goes and the
 * quad samples the texture across exactly that rect. There is no fit maths here on
 * purpose. A wall tile is already the photo's own shape — each figure declares the
 * photo's aspect ratio, so object-cover has nothing to crop — and the roll's rect
 * is computed to that shape too. Two rects of equal aspect stay that aspect through
 * a linear lerp, so the trip between them can't stretch the photo.
 *
 * Which is why the dissolve needs *two* rects rather than one. Consecutive frames in
 * a collection are not the same shape — a 1:1 followed by a 16:9 — so a single rect
 * shared by both textures would draw one of them stretched to the other's box for the
 * whole 620ms. Each layer is sampled in its own rect instead, and the pair simply
 * share a centre. Nothing here is ever distorted, which also makes this match the
 * <img> fallback in CameraRoll: two absolutely-centred images, each at its own
 * aspect ratio, crossfading.
 *
 * Both rects are normalised and y-up, matching ogl's Triangle uv and the flipY
 * texture upload; converting from y-down screen rects happens once, in apply().
 */
const FRAGMENT = /* glsl */ `
  precision highp float;
  varying vec2 vUv;
  uniform sampler2D tFrom;
  uniform sampler2D tTo;
  uniform vec4 uRectTo;
  uniform vec4 uRectFrom;
  uniform float uProgress;
  uniform float uHasFrom;
  uniform float uVisible;
  uniform vec2 uPixel;

  ${NOISE}

  /**
   * Coverage rather than a yes/no test. The rect's edges land on fractional pixels
   * and move every frame of a flight; deciding each pixel is wholly in or wholly
   * out makes that boundary snap from one column to the next, which reads as the
   * edge crawling. Fading across the last pixel costs one smoothstep and holds the
   * edge still.
   *
   * The pixel width is passed in rather than taken from fwidth(), which in GLSL ES
   * 1.00 needs an extension this doesn't otherwise require.
   */
  vec4 place(sampler2D tex, vec4 rect, float zoom) {
    vec2 p = (vUv - rect.xy) / rect.zw;
    p = (p - 0.5) / zoom + 0.5;

    vec2 px = uPixel / (rect.zw * zoom);
    vec2 edge = smoothstep(vec2(0.0), px, p) * (vec2(1.0) - smoothstep(vec2(1.0) - px, vec2(1.0), p));
    float cover = edge.x * edge.y;
    if (cover <= 0.0) return vec4(0.0);

    // Alpha only: the context is not premultiplied, so scaling rgb would darken
    // the edge towards black instead of fading it out.
    vec4 c = texture2D(tex, clamp(p, 0.0, 1.0));
    return vec4(c.rgb, c.a * cover);
  }

  void main() {
    // Ahead of the division, so a zero-size rect can never reach it.
    if (uVisible < 0.5) {
      gl_FragColor = vec4(0.0);
      return;
    }

    // A touch of counter-scale on each layer: the outgoing frame drifts out as the
    // incoming one settles, which reads as depth rather than as a fade.
    vec4 to = place(tTo, uRectTo, mix(0.98, 1.0, uProgress));

    // With nothing to come from, the frame just appears — which is also the case
    // right through a toggle, where one photo travels and nothing dissolves.
    if (uHasFrom < 0.5) {
      gl_FragColor = to;
      return;
    }

    vec4 from = place(tFrom, uRectFrom, mix(1.0, 1.02, uProgress));

    // Threshold the noise rather than the progress, so different regions turn over
    // at different moments. The remap guarantees a clean 0 and 1 at the ends.
    float n = vnoise(vUv * 5.0) * 0.6 + 0.2;
    float p = uProgress * 1.5 - 0.25;
    float t = smoothstep(n - 0.25, n + 0.25, p);

    gl_FragColor = mix(from, to, t);
  }
`;

type Loaded = { texture: Texture; aspect: number };

export type PhotoStage = {
  /** Match the drawing buffer to the viewport. Call on mount and on resize. */
  resize(): void;
  /**
   * Where the photo is drawn, in viewport CSS pixels. Null draws nothing.
   *
   * This places the *current* photo. Mid-dissolve the outgoing one keeps the rect it
   * had when `prepare` was called, so each frame is drawn in its own shape and a
   * step between two different aspect ratios distorts neither.
   */
  setRect(rect: Rect | null): void;
  /** Show `src` with no dissolve — first paint, or a jump. */
  show(src: string): Promise<void>;
  /**
   * Load `src` and hold it as the incoming frame. Drive `progress` once it resolves.
   *
   * Snapshots the current rect for the outgoing frame as it's called, before it
   * awaits anything — so place the incoming frame *after* this, not before.
   */
  prepare(src: string): Promise<void>;
  /** 0 = the outgoing frame, 1 = the incoming one. Draws immediately. */
  progress(p: number): void;
  /** Adopt the incoming frame as current. Call once progress reaches 1. */
  settle(): void;
  /** Decode and upload these ahead of being asked for, so a step can start at once. */
  warm(srcs: string[]): void;
  dispose(): void;
};

export function createPhotoStage(canvas: HTMLCanvasElement): PhotoStage | null {
  // Every failure here has to end as `null`, never a throw: this runs inside a
  // React effect, so an exception would take the gallery down rather than falling
  // back to the <img> — which is the entire point of the fallback.
  try {
    const renderer = new Renderer({
      canvas,
      alpha: true,
      antialias: false,
      // The photo is opaque where it exists; premultiplied alpha would darken the
      // soft edge of the dissolve against the grey field.
      premultipliedAlpha: false,
      dpr: Math.min(window.devicePixelRatio || 1, 2),
    });

    const gl = renderer.gl;
    if (!gl) return null;
    gl.clearColor(0, 0, 0, 0);

    const blank = new Texture(gl);
    const program = new Program(gl, {
      vertex: VERTEX,
      fragment: FRAGMENT,
      transparent: true,
      uniforms: {
        tFrom: { value: blank },
        tTo: { value: blank },
        uRectTo: { value: [0, 0, 1, 1] },
        uRectFrom: { value: [0, 0, 1, 1] },
        uProgress: { value: 1 },
        uHasFrom: { value: 0 },
        uVisible: { value: 0 },
        uPixel: { value: [1 / 1440, 1 / 900] },
      },
    });

    // A failed compile or link only makes ogl warn — it returns from the
    // constructor leaving the program half-built, and the throw lands later on the
    // first draw. uniformLocations is assigned just past that bail-out, so its
    // absence is the signal that this context is no good.
    if (!program.uniformLocations) return null;

    const mesh = new Mesh(gl, { geometry: new Triangle(gl), program });

    /** Decoded textures by src. One roll's worth of open photos, not the wall. */
    const cache = new Map<string, Loaded>();
    let current: Loaded | null = null;
    let incoming: Loaded | null = null;
    let currentSrc: string | null = null;
    let incomingSrc: string | null = null;
    /** The CSS size the rect maths is relative to, kept in step by resize. */
    let cssWidth = 1;
    let cssHeight = 1;
    let disposed = false;

    /**
     * Where each layer is drawn, in viewport CSS pixels. Kept unnormalised because
     * that's the form a resize needs: the normalised values are relative to a buffer
     * size that just changed, so both have to be recomputed from the pixels rather
     * than adjusted.
     *
     * `rectPx` is the live one — the photo the caller last placed. `fromPx` is where
     * the outgoing photo was resting when the dissolve began, held so it can keep its
     * own shape on the way out instead of being stretched into its successor's box.
     */
    let rectPx: Rect | null = null;
    let fromPx: Rect | null = null;

    const draw = () => {
      if (disposed) return;
      renderer.render({ scene: mesh });
    };

    /** Screen rects measure y downward; the quad's uv runs up. */
    const normalise = (r: Rect) => [
      r.x / cssWidth,
      (cssHeight - (r.y + r.height)) / cssHeight,
      r.width / cssWidth,
      r.height / cssHeight,
    ];

    /**
     * Push both rects to the GPU. Called by every path that moves the photo or
     * changes the buffer, so there's one place the y-flip and the normalising happen.
     *
     * A null `fromPx` falls back to the live rect rather than to zero: `place`
     * divides by the rect's size, and uHasFrom can legitimately still be 1 for a
     * frame if a dissolve is prepared before anything has been placed.
     */
    const apply = () => {
      if (disposed) return;
      if (!rectPx || rectPx.width <= 0 || rectPx.height <= 0) {
        program.uniforms.uVisible.value = 0;
        draw();
        return;
      }
      program.uniforms.uVisible.value = 1;
      program.uniforms.uRectTo.value = normalise(rectPx);
      program.uniforms.uRectFrom.value = normalise(
        fromPx && fromPx.width > 0 && fromPx.height > 0 ? fromPx : rectPx
      );
      draw();
    };

    /**
     * Evict in insertion order, skipping whatever is on screen or on its way in.
     * Map keeps that order for us, and a cache hit re-inserts, so this is least
     * recently used without a second structure to keep in step.
     */
    const trim = () => {
      for (const key of [...cache.keys()]) {
        if (cache.size <= CACHE_CAP) break;
        if (key === currentSrc || key === incomingSrc) continue;
        const dead = cache.get(key);
        if (dead) gl.deleteTexture(dead.texture.texture);
        cache.delete(key);
      }
    };

    /**
     * Resolves null rather than rejecting when the stage is torn down mid-load —
     * which happens on every StrictMode mount, and on any quick exit from the roll.
     * That isn't a failure, and making it one leaves an unhandled rejection in the
     * console for the routine case.
     */
    const load = (src: string): Promise<Loaded | null> => {
      const hit = cache.get(src);
      if (hit) {
        // Re-insert so the freshest sits at the back of the eviction order.
        cache.delete(src);
        cache.set(src, hit);
        return Promise.resolve(hit);
      }
      return new Promise((resolve, reject) => {
        const img = new Image();
        img.decoding = "async";
        img.onload = () => {
          if (disposed) return resolve(null);
          const texture = new Texture(gl, {
            image: img,
            // Drawn at or below their own size is exactly the case mipmaps are for:
            // a 2000px photo in a 355px tile samples one texel in five, so without
            // a mip chain the minified image aliases, and shimmers as the rect
            // moves sub-pixel through a flight. Trilinear rather than ogl's default
            // NEAREST_MIPMAP_LINEAR, so crossing between mip levels while the photo
            // grows doesn't step. ogl drops mips by itself for a non-power-of-two
            // image on WebGL1, where they aren't allowed.
            generateMipmaps: true,
            minFilter: gl.LINEAR_MIPMAP_LINEAR,
            magFilter: gl.LINEAR,
          });
          const loaded = { texture, aspect: img.naturalWidth / img.naturalHeight };
          cache.set(src, loaded);
          trim();
          resolve(loaded);
        };
        img.onerror = () => reject(new Error(`photoStage: ${src}`));
        img.src = src;
      });
    };

    return {
      /**
       * Sized to the viewport, since the canvas is fixed to it. Deliberately not
       * measured from the canvas: ogl's setSize writes inline width/height styles
       * and its constructor runs it once with its own 300x150 defaults, so the
       * element only ever reports back whatever ogl last told it.
       */
      resize() {
        if (disposed) return;
        const w = window.innerWidth;
        const h = window.innerHeight;
        if (!w || !h) return;
        cssWidth = w;
        cssHeight = h;
        renderer.setSize(w, h);
        // One device pixel, in the uv units the edge fade works in.
        program.uniforms.uPixel.value = [1 / (w * renderer.dpr), 1 / (h * renderer.dpr)];
        // Both rects are normalised against the size that just changed, so they're
        // stale until recomputed — including the outgoing one, mid-dissolve.
        apply();
      },

      setRect(rect) {
        rectPx = rect;
        apply();
      },

      async show(src) {
        const next = await load(src);
        if (!next || disposed) return;
        current = next;
        currentSrc = src;
        incoming = null;
        incomingSrc = null;
        fromPx = null;
        program.uniforms.tTo.value = next.texture;
        program.uniforms.uHasFrom.value = 0;
        program.uniforms.uProgress.value = 1;
        draw();
      },

      async prepare(src) {
        // Before the await, so it's the box the outgoing photo is resting in right
        // now. The caller places the incoming photo straight after this returns —
        // see the note on ordering in usePhotoStage — and that call would otherwise
        // have already overwritten the only record of where its predecessor was.
        fromPx = rectPx;
        const next = await load(src);
        if (!next || disposed) return;
        incoming = next;
        incomingSrc = src;
        if (current) {
          program.uniforms.tFrom.value = current.texture;
          program.uniforms.uHasFrom.value = 1;
        } else {
          program.uniforms.uHasFrom.value = 0;
        }
        program.uniforms.tTo.value = next.texture;
        program.uniforms.uProgress.value = 0;
      },

      progress(p) {
        if (disposed) return;
        program.uniforms.uProgress.value = p;
        draw();
      },

      settle() {
        if (incoming) {
          current = incoming;
          currentSrc = incomingSrc;
        }
        incoming = null;
        incomingSrc = null;
        // The photo that was on its way out is gone, so its box goes with it.
        fromPx = null;
        program.uniforms.uHasFrom.value = 0;
        program.uniforms.uProgress.value = 1;
        draw();
      },

      warm(srcs) {
        if (disposed) return;
        for (const src of srcs) {
          if (!src || cache.has(src)) continue;
          // Fire and forget: a warm that fails just means the step pays for the
          // decode itself, which is what would have happened anyway.
          void load(src).catch(() => null);
        }
      },

      dispose() {
        disposed = true;
        for (const { texture } of cache.values()) gl.deleteTexture(texture.texture);
        cache.clear();
        current = null;
        incoming = null;
        // Deliberately not WEBGL_lose_context. Losing it frees the GPU memory a
        // little sooner, but it poisons this canvas element for good — and React
        // reuses the node across a remount (StrictMode does it on every mount), so
        // the next stage would get a dead context whose shaders won't compile and
        // no info log to say why. Dropping the textures and the references is
        // enough; the context goes with the element.
      },
    };
  } catch {
    return null; // No WebGL, or a context that can't be set up.
  }
}

export { containRect } from "./rect.js";
packages/ui/src/lib/rect.ts12 lines
/** A box in CSS pixels, viewport-relative — the shape getBoundingClientRect gives. */
export type Rect = { x: number; y: number; width: number; height: number };

export const rectOf = (r: DOMRect): Rect => ({ x: r.x, y: r.y, width: r.width, height: r.height });

/** A photo's own box inside a container, the way `object-fit: contain` places it. */
export function containRect(box: Rect, aspect: number): Rect {
  const wide = box.width / box.height > aspect;
  const width = wide ? box.height * aspect : box.width;
  const height = wide ? box.height : box.width / aspect;
  return { x: box.x + (box.width - width) / 2, y: box.y + (box.height - height) / 2, width, height };
}