Ray-Traced Light Box History Timeline with a Sliding Film Negative Strip and a Print That Develops Beside It

A company story told the way an archivist would go through it. In a dim room a long light box glows on a walnut desk, and a strip of colour negatives lies across the opal glass, one frame for each year. Pick a year and the strip slides along until that frame rests over the brighter lamp in the middle of the box. The lamp dims while the strip is moving and swells again once it stops. On the desk under a warm lamp, the print of that frame clears back to white paper and then comes up again the way a print does in developer: shadows first, then mid tones, then the colour settles. The year, a short heading and a sentence are set in real type beside it. The whole scene is traced in a single fragment shader using your own photographs, which are turned into true negatives on the strip. On phones the box gets shorter, the camera rises to look down on it, and the print and text sit underneath.

Code

import { useEffect, useRef, useState } from "react";

// The years. Each one is a frame on the strip of negatives, and the print on the desk beside the box.
const years = [
  { year: "1968", title: "A wheel in the coal shed", body: "Ada Marrow buys a kick wheel at a school sale and throws her first mugs in the shed behind the house, firing them in a neighbour's kiln.", photo: "https://images.pexels.com/photos/2556276/pexels-photo-2556276.jpeg?auto=compress&cs=tinysrgb&w=640", tone: "#8a7a6c" },
  { year: "1974", title: "Slabs, tools and a first order", body: "A café on the high street asks for sixty plates. She learns slab work to make them all the same size, and the kitchen table becomes a bench.", photo: "https://images.pexels.com/photos/5642023/pexels-photo-5642023.jpeg?auto=compress&cs=tinysrgb&w=640", tone: "#a39a90" },
  { year: "1981", title: "The big kiln is lit", body: "The pottery moves into a brick workshop on Tenter Street and builds its own kiln. Whole shelves of plates go in at once and come out warm a day later.", photo: "https://images.pexels.com/photos/9736509/pexels-photo-9736509.jpeg?auto=compress&cs=tinysrgb&w=640", tone: "#8c6a4f" },
  { year: "1993", title: "Bowls by the stack", body: "Four throwers now share the wheel room. The plain brown bowl that started as a seconds line becomes the piece people ask for by name.", photo: "https://images.pexels.com/photos/33703946/pexels-photo-33703946.jpeg?auto=compress&cs=tinysrgb&w=640", tone: "#6b4a34" },
  { year: "2006", title: "A shop with the door open", body: "The front room is cleared and shelved. Visitors can buy straight from the racks and watch the kiln being loaded through the back window.", photo: "https://images.pexels.com/photos/6611184/pexels-photo-6611184.jpeg?auto=compress&cs=tinysrgb&w=640", tone: "#9a9894" },
  { year: "2019", title: "Handed to the next pair", body: "Ada's apprentice of twelve years takes over the kiln book. The glazes, the stamp on the foot and the Friday firing all stay the same.", photo: "https://images.pexels.com/photos/6023601/pexels-photo-6023601.jpeg?auto=compress&cs=tinysrgb&w=640", tone: "#b49a80" },
];
const START = 2; // the year it opens on
const COMPANY = "Marrow Lane Pottery";
const STRAP = "Our story, one frame at a time";
const FONT = '"Inter Tight", "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif';
const SERIF = '"Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif';
const N = years.length;

// Scene units. Frame k of the strip sits at x = (k - strip) * PITCH, so the chosen frame comes to rest at x = 0.
const PITCH = 1.1, TOP = 0.42;

const VERT = `ATTR vec2 aPos; void main(){ gl_Position = vec4(aPos, 0.0, 1.0); }`;
const TONE = `
vec3 tone(vec3 x){ x *= 0.85; vec3 a = x * (2.51 * x + 0.03); vec3 b = x * (2.43 * x + 0.59) + 0.14; return pow(clamp(a / b, 0.0, 1.0), vec3(1.0 / 2.2)); }
`;

// A light box in a dim room: opal glass lit from inside, a strip of colour negatives across it,
// a desk lamp over a print lying on a dark walnut desk.
const SCENE = `
precision highp float;
uniform vec2 uRes; uniform vec4 uJit; uniform float uSeed;
uniform vec3 uCam; uniform vec3 uTgt; uniform float uFoc; uniform vec2 uShift; uniform float uFocus;
uniform float uSel; uniform float uHot; uniform float uCur; uniform float uDev;
uniform vec2 uBox; uniform vec4 uPrint; uniform float uRot; uniform vec3 uLamp;
uniform sampler2D uTex; uniform float uDirect;
const float H = ${TOP.toFixed(2)}; const float FY = ${(TOP + 0.008).toFixed(3)}; const float SW = 0.52; const float P = ${PITCH.toFixed(2)};
const float IX = 0.5; const float IZ = 0.335; const float NF = ${N.toFixed(1)};
const vec3 GLOW = vec3(1.0, 0.95, 0.86);
const vec3 LAMP = vec3(1.0, 0.74, 0.46);
const vec3 BASE = vec3(0.98, 0.6, 0.3);
${TONE}
float h21(vec2 p){ vec3 q = fract(vec3(p.xyx) * 0.1031); q += dot(q, q.yzx + 33.33); return fract((q.x + q.y) * q.z); }
float n2(vec2 x){ vec2 i = floor(x); vec2 f = fract(x); f = f * f * (3.0 - 2.0 * f);
  return mix(mix(h21(i), h21(i + vec2(1.0, 0.0)), f.x), mix(h21(i + vec2(0.0, 1.0)), h21(i + vec2(1.0, 1.0)), f.x), f.y); }
float sd2Box(vec2 p, vec2 b, float r){ vec2 q = abs(p) - b + r; return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; }
vec2 boxSpan(vec3 ro, vec3 rd, vec3 lo, vec3 hi){
  vec3 inv = 1.0 / (rd + vec3(1e-6)); vec3 a = (lo - ro) * inv; vec3 b = (hi - ro) * inv;
  vec3 mn = min(a, b); vec3 mx = max(a, b);
  return vec2(max(max(mn.x, mn.y), max(mn.z, 0.0)), min(mx.x, min(mx.y, mx.z)));
}
float rnd(float k){ return h21(gl_FragCoord.xy * 0.913 + vec2(uSeed * 97.1 + k, k * 13.7)); }

// ---- the photographs, from the atlas: 3 across, 2 down ----
vec3 photoS(float k, vec2 uv){
  uv = clamp(uv, 0.003, 0.997);
  vec2 cell = vec2(mod(k, 3.0), floor(k / 3.0));
  return texture2D(uTex, (cell + vec2(uv.x, 1.0 - uv.y)) / vec2(3.0, 2.0)).rgb;
}
vec3 photo(float k, vec2 uv){ return pow(photoS(k, uv), vec3(2.2)); }

// ---- the opal glass: an even glow, brighter where the lamp inside sits under the chosen frame ----
float glassEmit(vec2 xz){
  float edge = min(uBox.x - abs(xz.x), uBox.y - abs(xz.y));
  float e = 0.9 * (0.62 + 0.38 * smoothstep(0.0, 1.2, edge)) * (1.0 - 0.18 * smoothstep(0.0, 1.0, abs(xz.x) / uBox.x));
  float r = sd2Box(xz, vec2(IX + 0.1, SW + 0.02), 0.1);
  e += uHot * 0.75 * smoothstep(0.05, -0.03, r) + uHot * 0.25 * smoothstep(0.5, 0.0, r);
  e *= 0.97 + 0.06 * n2(xz * 23.0);
  return e;
}

// ---- the film: orange base, clear sprocket holes, the image as a colour negative ----
vec3 filmTrans(vec2 xz){
  float u = xz.x / P + uSel;
  float k = floor(u + 0.5);
  float fx = (u - k) * P;
  float az = abs(xz.y);
  vec3 tr = BASE * 0.62;
  if (az > 0.39 && az < 0.48) {
    float hx = mod(xz.x + uSel * P, P / 8.0) - P / 16.0;
    float hole = sd2Box(vec2(hx, az - 0.435), vec2(0.026, 0.02), 0.008);
    tr = mix(vec3(1.0), tr, smoothstep(-0.002, 0.003, hole));
  }
  if (abs(fx) < IX && az < IZ) {
    vec2 uv = vec2(fx / (2.0 * IX) + 0.5, xz.y / (2.0 * IZ) + 0.5);
    float inside = smoothstep(0.0, 0.006, IX - abs(fx)) * smoothstep(0.0, 0.006, IZ - az);
    vec3 neg;
    if (k >= 0.0 && k < NF) {
      vec3 ph = photoS(k, uv);
      neg = BASE * (vec3(0.02) + 0.95 * pow(clamp(1.0 - ph * 1.08, 0.0, 1.0), vec3(2.3)));
    } else {
      neg = BASE * 0.06; // the fogged leader and tail
    }
    tr = mix(tr, neg, inside);
  }
  // a little edge printing along the far side: dashes and a frame mark
  if (xz.y < -0.355 && xz.y > -0.385) {
    float d = mod(xz.x + uSel * P + 0.2, P);
    float mark = step(0.08, d) * step(d, 0.2) + step(0.5, d) * step(d, 0.53) + step(0.58, d) * step(d, 0.61);
    tr *= 1.0 - 0.75 * mark;
  }
  tr *= 0.35 + 0.65 * smoothstep(0.0, 0.012, SW - az);
  return tr;
}
bool onStrip(vec2 xz){
  float a = (-1.4 - uSel) * P, b = (NF + 0.4 - uSel) * P;
  return abs(xz.y) < SW && xz.x > a && xz.x < b;
}

// What glossy things see: a dark room, a pale ceiling bounce, and the lamp's warm bulb.
vec3 env(vec3 r){
  vec3 c = mix(vec3(0.010, 0.009, 0.008), vec3(0.03, 0.028, 0.026), smoothstep(-0.1, 0.8, r.y));
  vec3 d = normalize(uLamp - vec3(0.0, 0.4, 1.0));
  c += LAMP * 22.0 * smoothstep(0.986, 0.996, dot(r, d));
  return c;
}

// Light at a point on the desk or print: the lamp (with a shadow from the box) and the box's spill.
vec3 deskLight(vec3 p, vec3 n){
  vec3 lp = uLamp + vec3(rnd(1.0) - 0.5, 0.0, rnd(2.0) - 0.5) * 0.7;
  vec3 L = lp - p; float d = length(L); L /= d;
  float spot = smoothstep(0.8, 0.95, dot(-L, normalize(vec3(uPrint.x, 0.0, uPrint.y) - uLamp)));
  float sh = 1.0;
  vec2 tb = boxSpan(p + n * 0.002, L, vec3(-uBox.x, 0.0, -uBox.y), vec3(uBox.x, H, uBox.y));
  if (tb.x < tb.y && tb.x < d) sh = 0.0;
  vec3 c = LAMP * 30.0 * max(dot(n, L), 0.0) / (d * d) * (0.04 + 0.96 * spot) * sh;
  float fd = sd2Box(p.xz, uBox, 0.04);
  c += GLOW * 0.85 * exp(-max(fd, 0.0) * 2.6) * (0.35 + 0.65 * smoothstep(0.0, 0.2, fd));
  c += vec3(0.012, 0.011, 0.01);
  return c;
}

// The box seen in a reflection: its glowing glass edge and its dark painted body.
vec3 boxSeen(vec3 ro, vec3 rd){
  vec2 tb = boxSpan(ro, rd, vec3(-uBox.x, 0.0, -uBox.y), vec3(uBox.x, H, uBox.y));
  if (tb.x < tb.y && tb.x > 0.001) {
    vec3 p = ro + rd * tb.x;
    if (p.y > H - 0.055) return GLOW * 0.9;
    return vec3(0.02, 0.019, 0.018);
  }
  return env(rd);
}

vec3 shadeDesk(vec3 p, vec3 rd){
  float w = n2(vec2(p.x * 0.35, p.z * 2.2)) * 1.6 + n2(vec2(p.x * 1.4, p.z * 6.0)) * 0.3;
  float ring = sin(p.z * 34.0 + w * 6.0);
  float fine = n2(vec2(p.x * 2.0, p.z * 160.0));
  vec3 a = vec3(0.075, 0.045, 0.028) * (0.82 + 0.12 * ring + 0.14 * fine);
  float fd = sd2Box(p.xz, uBox, 0.04);
  float occ = 0.3 + 0.7 * smoothstep(0.0, 0.35, fd);
  vec2 q = p.xz - uPrint.xy; float cr = cos(uRot), sr = sin(uRot); q = vec2(cr * q.x - sr * q.y, sr * q.x + cr * q.y);
  occ *= 0.45 + 0.55 * smoothstep(-0.01, 0.07, sd2Box(q, uPrint.zw, 0.01));
  // the end of the strip that hangs past the case throws a soft shadow just beside it
  vec2 sp = p.xz + vec2(0.0, 0.12);
  if (onStrip(vec2(sp.x, 0.0)) && fd > 0.0) occ *= mix(1.0, 0.35, smoothstep(SW + 0.08, SW - 0.12, abs(sp.y)));
  vec3 n = vec3(0.0, 1.0, 0.0);
  vec3 col = a * deskLight(p, n) * occ;
  // satin varnish: a blurred reflection of the box edge and the lamp
  vec3 nn = normalize(n + vec3(rnd(3.0) - 0.5, 0.0, rnd(4.0) - 0.5) * 0.16);
  vec3 r = reflect(rd, nn);
  float F = 0.03 + 0.97 * pow(1.0 - clamp(dot(-rd, nn), 0.0, 1.0), 5.0);
  col += F * boxSeen(p + n * 0.002, r) * 0.55 * occ;
  return col;
}

vec3 shadePrint(vec3 p, vec3 rd, vec2 q){
  vec2 inner = uPrint.zw - vec2(0.085);
  vec3 paper = vec3(0.86, 0.84, 0.8);
  vec3 a = paper;
  if (abs(q.x) < inner.x && abs(q.y) < inner.y) {
    vec2 uv = vec2(q.x / (2.0 * inner.x) + 0.5, q.y / (2.0 * inner.y) + 0.5);
    vec3 ph = photo(uCur, uv) * 0.92;
    float l = dot(ph, vec3(0.3, 0.55, 0.15));
    // shadows come up first, then the mid tones, then the colour settles
    float dev = smoothstep(0.0, 1.0, uDev * 1.7 - pow(l, 0.5) * 0.7);
    vec3 warm = mix(paper, ph * vec3(1.08, 0.96, 0.84), dev);
    a = mix(warm, ph, smoothstep(0.55, 1.0, uDev));
  }
  vec3 n = vec3(0.0, 1.0, 0.0);
  vec3 col = a * deskLight(p, n);
  vec3 r = reflect(rd, normalize(n + vec3(rnd(5.0) - 0.5, 0.0, rnd(6.0) - 0.5) * 0.2));
  float F = 0.035 + 0.965 * pow(1.0 - clamp(dot(-rd, n), 0.0, 1.0), 5.0);
  col += F * boxSeen(p + n * 0.002, r) * 0.6;
  return col;
}

vec3 shadeFilm(vec3 p, vec3 rd, bool over){
  vec3 tr = filmTrans(p.xz);
  vec3 col;
  if (over) col = GLOW * glassEmit(p.xz) * tr;
  else col = tr * 0.25 * deskLight(p, vec3(0.0, 1.0, 0.0)) * vec3(0.5, 0.35, 0.25) + BASE * 0.006;
  vec3 n = normalize(vec3(0.0, 1.0, 0.0) + vec3(n2(p.xz * 3.0) - 0.5, 0.0, n2(p.zx * 3.0 + 7.0) - 0.5) * 0.03);
  vec3 r = reflect(rd, n);
  float F = 0.04 + 0.96 * pow(1.0 - clamp(dot(-rd, n), 0.0, 1.0), 5.0);
  col += F * env(r) * 1.2;
  return col;
}

vec3 shadeBox(vec3 p, vec3 rd){
  vec3 q = p / vec3(uBox.x, H, uBox.y);
  vec3 a = abs(q);
  vec3 n = a.x > a.z ? vec3(sign(q.x), 0.0, 0.0) : vec3(0.0, 0.0, sign(q.z));
  if (a.y > max(a.x, a.z) && q.y > 0.0) n = vec3(0.0, 1.0, 0.0);
  if (n.y > 0.5) {
    // a satin aluminium bezel holds the glass
    float rim = min(uBox.x - abs(p.x), uBox.y - abs(p.z));
    if (rim < 0.11) {
      vec3 r = reflect(rd, n);
      float lip = smoothstep(0.11, 0.095, rim) * smoothstep(0.0, 0.02, rim);
      vec3 al = vec3(0.09, 0.088, 0.085) * deskLight(p, n) * 2.0 + env(r) * 0.35;
      al += GLOW * 0.5 * exp(-(0.11 - rim) * 30.0) * 0.4;
      return mix(al, al * 1.8, 1.0 - lip) * (0.6 + 0.4 * smoothstep(0.0, 0.015, rim));
    }
    // the bare glass round the strip
    vec3 col = GLOW * glassEmit(p.xz);
    vec3 r = reflect(rd, n);
    col += (0.04 + 0.96 * pow(1.0 - clamp(dot(-rd, n), 0.0, 1.0), 5.0)) * env(r);
    return col;
  }
  // the edge of the opal glass shows as a glowing band along the top of the case
  float band = smoothstep(H - 0.058, H - 0.05, p.y);
  float lip = smoothstep(H - 0.075, H - 0.06, p.y) * (1.0 - band);
  vec3 body = vec3(0.03, 0.029, 0.028);
  vec3 col = body * deskLight(p, n) * 1.4;
  vec3 r = reflect(rd, n);
  col += (0.05 + 0.95 * pow(1.0 - clamp(dot(-rd, n), 0.0, 1.0), 5.0)) * env(r) * 0.8;
  col += vec3(0.5, 0.48, 0.45) * lip * 0.08;
  col = mix(col, GLOW * vec3(0.92, 1.0, 0.94) * 0.9, band);
  // the case sits in its own glow at the foot
  col *= 0.55 + 0.45 * smoothstep(0.0, 0.12, p.y);
  return col;
}

void main(){
  vec2 fc = gl_FragCoord.xy + uJit.xy;
  vec2 uv = (fc - 0.5 * uRes) / uRes.y - uShift;
  vec3 fw = normalize(uTgt - uCam); vec3 rt = normalize(cross(fw, vec3(0.0, 1.0, 0.0))); vec3 up = cross(rt, fw);
  vec3 rd = normalize(fw * uFoc + rt * uv.x + up * uv.y);
  vec3 ro = uCam;
  vec3 fp = ro + rd * (uFocus / dot(rd, fw));
  ro += rt * uJit.z + up * uJit.w; rd = normalize(fp - ro);

  float best = 1e9; int what = 0; vec2 pq = vec2(0.0);
  if (rd.y < 0.0) {
    float tf = (FY - ro.y) / rd.y; vec3 p = ro + rd * tf;
    if (onStrip(p.xz)) { best = tf; what = 1; }
    float tp = (0.006 - ro.y) / rd.y; p = ro + rd * tp;
    vec2 q = p.xz - uPrint.xy; float cr = cos(uRot), sr = sin(uRot); q = vec2(cr * q.x - sr * q.y, sr * q.x + cr * q.y);
    if (tp < best && abs(q.x) < uPrint.z && abs(q.y) < uPrint.w) { best = tp; what = 2; pq = q; }
  }
  vec2 tb = boxSpan(ro, rd, vec3(-uBox.x, 0.0, -uBox.y), vec3(uBox.x, H, uBox.y));
  if (tb.x < tb.y && tb.x < best) { best = tb.x; what = 3; }
  if (what == 0 && rd.y < 0.0) { best = -ro.y / rd.y; what = 4; }

  vec3 col = vec3(0.004);
  vec3 p = ro + rd * best;
  if (what == 1) col = shadeFilm(p, rd, abs(p.x) < uBox.x && abs(p.z) < uBox.y);
  else if (what == 2) col = shadePrint(p, rd, pq);
  else if (what == 3) col = shadeBox(p, rd);
  else if (what == 4) col = shadeDesk(p, rd) * exp(-max(best - 18.0, 0.0) * 0.15);
  col = min(col, vec3(30.0));
  if (uDirect > 0.5) col = tone(col);
  outC = vec4(col, 1.0);
}
`;

// Developing the plate: bloom off the glass, the filmic curve, a vignette and grain.
const POST = `
precision highp float;
uniform sampler2D uAcc; uniform vec2 uRes; uniform float uGrain; uniform vec2 uCentre;
${TONE}
float h21(vec2 p){ vec3 q = fract(vec3(p.xyx) * 0.1031); q += dot(q, q.yzx + 33.33); return fract((q.x + q.y) * q.z); }
void main(){
  vec2 uv = gl_FragCoord.xy / uRes;
  vec3 c = texture2D(uAcc, uv).rgb;
  vec3 b = vec3(0.0);
  for (int i = 0; i < 24; i++) {
    float a = float(i) * 2.39996; float r = sqrt((float(i) + 0.5) / 24.0);
    vec3 s = texture2D(uAcc, uv + vec2(cos(a), sin(a)) * r * 0.035 * vec2(uRes.y / uRes.x, 1.0)).rgb;
    b += min(max(s - 1.6, 0.0), vec3(8.0)) * (1.0 - 0.7 * r);
  }
  c += b * 0.012 * vec3(1.0, 0.9, 0.76);
  vec2 q = (uv - uCentre) * vec2(uRes.x / uRes.y, 1.0);
  c *= 1.0 - 0.35 * smoothstep(0.3, 1.2, length(q));
  c = tone(c);
  c += (h21(gl_FragCoord.xy + uGrain * 91.7) - 0.5) * 0.022;
  outC = vec4(c, 1.0);
}
`;

// No WebGL: the same desk built from CSS, with the photographs themselves.
function Still({ act }: { act: number }) {
  return (
    <div aria-hidden="true" className="absolute inset-x-4 top-[14%] sm:inset-x-10">
      <div className="relative mx-auto h-[170px] max-w-[900px] overflow-hidden rounded-[6px]" style={{ background: "#f3ede2", boxShadow: "0 0 80px 10px rgba(255,236,205,0.18), inset 0 0 40px rgba(220,200,170,0.6)" }}>
        <div className="absolute left-1/2 top-[30px] flex h-[110px] items-center gap-[6px] px-2 transition-transform duration-700 ease-[cubic-bezier(0.3,1.3,0.5,1)] motion-reduce:transition-none" style={{ background: "rgba(214,128,62,0.82)", transform: `translateX(${-(78 + act * 146)}px)`, width: N * 146 + 10 }}>
          {years.map((y, i) => (
            <div key={y.year} className="h-[86px] w-[140px] shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${y.photo})`, backgroundColor: y.tone, filter: "invert(1) sepia(0.6) saturate(2.2) hue-rotate(-12deg)", opacity: i === act ? 1 : 0.8 }} />
          ))}
        </div>
      </div>
    </div>
  );
}

export default function LightBoxHistoryTimeline() {
  const stage = useRef<HTMLDivElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const tags = useRef<(HTMLButtonElement | null)[]>([]);
  const [act, setAct] = useState(START);
  const [shown, setShown] = useState<"wait" | "gl" | "still">("wait");
  const actRef = useRef(START);
  const api = useRef<{ go: (i: number, fromUser: boolean) => void } | null>(null);

  useEffect(() => {
    const box = stage.current, cv = canvas.current;
    if (!box || !cv) return;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const opts = { antialias: false, alpha: false, powerPreference: "high-performance" as const };
    const gl2 = cv.getContext("webgl2", opts) as WebGL2RenderingContext | null;
    const gl = (gl2 ?? cv.getContext("webgl", opts)) as WebGLRenderingContext | null;
    if (!gl) { setShown("still"); return; }
    const canAcc = !!gl2 && (!!gl2.getExtension("EXT_color_buffer_float") || !!gl2.getExtension("EXT_color_buffer_half_float"));
    const info = gl.getExtension("WEBGL_debug_renderer_info");
    const renderer = info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : "";
    const software = /swiftshader|llvmpipe|software|basic render/i.test(renderer);
    const still = reduce || software;

    const head = gl2 ? "#version 300 es\n#define ATTR in\n" : "#define ATTR attribute\n";
    const fhead = gl2 ? "#version 300 es\n#define texture2D texture\nprecision highp float;\nout vec4 outC;\n" : "#define outC gl_FragColor\n";
    const compile = (type: number, src: string) => {
      const sh = gl.createShader(type);
      if (!sh) return null;
      gl.shaderSource(sh, src); gl.compileShader(sh);
      return gl.getShaderParameter(sh, gl.COMPILE_STATUS) ? sh : null;
    };
    const link = (frag: string) => {
      const v = compile(gl.VERTEX_SHADER, head + VERT), f = compile(gl.FRAGMENT_SHADER, fhead + frag);
      const p = gl.createProgram();
      if (!v || !f || !p) return null;
      gl.attachShader(p, v); gl.attachShader(p, f); gl.bindAttribLocation(p, 0, "aPos"); gl.linkProgram(p);
      return gl.getProgramParameter(p, gl.LINK_STATUS) ? p : null;
    };
    const scene = link(SCENE), post = canAcc ? link(POST) : null;
    if (!scene || (canAcc && !post)) { setShown("still"); return; }
    const U = (p: WebGLProgram, n: string) => gl.getUniformLocation(p, n);
    const su = Object.fromEntries(["uRes", "uJit", "uSeed", "uCam", "uTgt", "uFoc", "uShift", "uFocus", "uSel", "uHot", "uCur", "uDev", "uBox", "uPrint", "uRot", "uLamp", "uTex", "uDirect"].map((n) => [n, U(scene, n)]));
    const pu = post ? Object.fromEntries(["uAcc", "uRes", "uGrain", "uCentre"].map((n) => [n, U(post, n)])) : {};

    const quad = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, quad);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
    gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);

    // The photographs go into one atlas, 3 across and 2 down, each cropped to the 3:2 frame.
    const CW = 480, CH = 320;
    const atlas = document.createElement("canvas");
    atlas.width = CW * 3; atlas.height = CH * 2;
    const ag = atlas.getContext("2d");
    const tex = gl.createTexture();
    const upload = () => {
      gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tex);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas);
      gl.generateMipmap(gl.TEXTURE_2D);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    };
    if (ag) years.forEach((y, i) => { ag.fillStyle = y.tone; ag.fillRect((i % 3) * CW, Math.floor(i / 3) * CH, CW, CH); });
    upload();
    let alive = true;
    years.forEach((y, i) => {
      const img = new Image();
      img.crossOrigin = "anonymous";
      img.onload = () => {
        if (!alive || !ag) return;
        const k = Math.max(CW / img.naturalWidth, CH / img.naturalHeight);
        const w = img.naturalWidth * k, h = img.naturalHeight * k;
        ag.drawImage(img, (i % 3) * CW + (CW - w) / 2, Math.floor(i / 3) * CH + (CH - h) / 2, w, h);
        upload(); s.count = 0; s.fresh = true; wake();
      };
      img.src = y.photo;
    });

    const acc = canAcc ? gl.createTexture() : null;
    const fbo = canAcc ? gl.createFramebuffer() : null;
    let W = 0, H = 0, scale = 1, cssW = 1, cssH = 1;
    const s = {
      sel: START, v: 0, target: START, hot: 1, cur: START, dev: 1, want: START,
      px: 0, py: 0, sx: 0, sy: 0, count: 0, fresh: true, frame: 0, slow: 0,
      visible: true, raf: 0, last: 0, t0: 0, touched: false, demoAt: 0, first: true,
    };
    const size = () => {
      const r = box.getBoundingClientRect();
      cssW = Math.max(1, r.width); cssH = Math.max(1, r.height);
      const dpr = Math.min(software ? 1 : 1.5, window.devicePixelRatio || 1) * scale;
      const k = Math.min(1, Math.sqrt(1.4e6 / (cssW * cssH * dpr * dpr)));
      const w = Math.max(2, Math.round(cssW * dpr * k)), h = Math.max(2, Math.round(cssH * dpr * k));
      if (w === W && h === H) return;
      W = w; H = h; cv.width = W; cv.height = H;
      if (gl2 && acc && fbo) {
        gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, acc);
        gl2.texImage2D(gl.TEXTURE_2D, 0, gl2.RGBA16F, W, H, 0, gl.RGBA, gl2.HALF_FLOAT, null);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, acc, 0);
        gl.bindFramebuffer(gl.FRAMEBUFFER, null);
      }
      s.count = 0; s.fresh = true;
    };
    const halton = (i: number, b: number) => { let f = 1, r = 0; while (i > 0) { f /= b; r += f * (i % b); i = Math.floor(i / b); } return r; };

    // Wide screens: a long box across the top, the print lying in front of it to the left.
    // Narrow screens: a shorter box seen from higher up, the print below it.
    const layout = () => {
      const wide = cssW >= 900, aspect = cssW / cssH;
      if (wide) return {
        wide, box: [4.6, 1.4], print: [-2.55, 3.55, 1.42, 0.98], rot: 0.075, lamp: [-3.6, 5.2, 5.6],
        tgt: [0.15, 0.0, 1.75], el: 0.74, az: 0.04, dist: 13.2, foc: 1.95 * Math.min(1, aspect / 1.75), shift: [0.0, -0.02],
      };
      return {
        wide, box: [2.25, 1.15], print: [0.05, 3.25, 1.32, 0.9], rot: -0.06, lamp: [-1.6, 5.4, 5.2],
        tgt: [0.0, 0.0, 1.9], el: 1.0, az: 0.0, dist: 11.0, foc: 1.12 * Math.min(1.2, aspect / 0.5), shift: [0.0, 0.15],
      };
    };
    const camera = () => {
      const l = layout();
      const el = l.el - s.sy * 0.02, az = l.az + s.sx * 0.05;
      const tgt = l.tgt;
      const cam = [tgt[0] + l.dist * Math.sin(az) * Math.cos(el), tgt[1] + l.dist * Math.sin(el), tgt[2] + l.dist * Math.cos(az) * Math.cos(el)];
      const fw = [tgt[0] - cam[0], tgt[1] - cam[1], tgt[2] - cam[2]];
      const fl = Math.hypot(fw[0], fw[1], fw[2]); fw[0] /= fl; fw[1] /= fl; fw[2] /= fl;
      const rt = [-fw[2], 0, fw[0]]; const rl = Math.hypot(rt[0], rt[2]); rt[0] /= rl; rt[2] /= rl;
      const up = [rt[1] * fw[2] - rt[2] * fw[1], rt[2] * fw[0] - rt[0] * fw[2], rt[0] * fw[1] - rt[1] * fw[0]];
      return { ...l, cam, fw, rt, up };
    };
    type Cam = ReturnType<typeof camera>;
    const project = (c: Cam, p: number[]) => {
      const d = [p[0] - c.cam[0], p[1] - c.cam[1], p[2] - c.cam[2]];
      const z = d[0] * c.fw[0] + d[1] * c.fw[1] + d[2] * c.fw[2];
      const x = (c.foc * (d[0] * c.rt[0] + d[1] * c.rt[1] + d[2] * c.rt[2])) / z + c.shift[0];
      const y = (c.foc * (d[0] * c.up[0] + d[1] * c.up[1] + d[2] * c.up[2])) / z + c.shift[1];
      return [cssW / 2 + x * cssH, cssH / 2 - y * cssH];
    };
    // The year buttons ride on the desk under their frames and slide with the strip.
    const placeTags = (c: Cam) => {
      for (let i = 0; i < N; i++) {
        const el = tags.current[i];
        if (!el) continue;
        const x = (i - s.sel) * PITCH;
        const [sx, sy] = project(c, [x, 0, c.box[1] + 0.28]);
        const off = Math.abs(x) > c.box[0] + 0.3;
        el.style.transform = `translate(${sx.toFixed(1)}px, ${sy.toFixed(1)}px) translate(-50%, 0)`;
        el.style.opacity = off ? "0.35" : "1";
      }
    };

    const draw = (alpha: number, sample: number) => {
      const c = camera();
      placeTags(c);
      const jx = sample ? halton(sample, 2) - 0.5 : 0, jy = sample ? halton(sample, 3) - 0.5 : 0;
      const la = halton(sample, 5) * Math.PI * 2, lr = sample ? Math.sqrt(halton(sample, 7)) * 0.05 : 0;
      gl.useProgram(scene);
      gl.bindFramebuffer(gl.FRAMEBUFFER, canAcc ? fbo : null);
      gl.viewport(0, 0, W, H);
      if (canAcc && alpha < 1) { gl.enable(gl.BLEND); gl.blendColor(0, 0, 0, alpha); gl.blendFunc(gl.CONSTANT_ALPHA, gl.ONE_MINUS_CONSTANT_ALPHA); } else gl.disable(gl.BLEND);
      gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tex);
      gl.uniform2f(su.uRes, W, H);
      gl.uniform4f(su.uJit, canAcc ? jx : 0, canAcc ? jy : 0, canAcc ? Math.cos(la) * lr : 0, canAcc ? Math.sin(la) * lr : 0);
      gl.uniform1f(su.uSeed, canAcc && sample ? (sample * 0.61803) % 1 || 0.5 : 0.37);
      gl.uniform3f(su.uCam, c.cam[0], c.cam[1], c.cam[2]); gl.uniform3f(su.uTgt, c.tgt[0], c.tgt[1], c.tgt[2]);
      gl.uniform1f(su.uFoc, c.foc);
      gl.uniform2f(su.uShift, c.shift[0], c.shift[1]);
      // focus halfway between the lit frame and the print, so both stay crisp and the far desk falls away
      const fx = (c.print[0]) * 0.4, fz = c.print[1] * 0.45;
      gl.uniform1f(su.uFocus, (fx - c.cam[0]) * c.fw[0] + (0.2 - c.cam[1]) * c.fw[1] + (fz - c.cam[2]) * c.fw[2]);
      gl.uniform1f(su.uSel, s.sel); gl.uniform1f(su.uHot, s.hot);
      gl.uniform1f(su.uCur, s.cur); gl.uniform1f(su.uDev, s.dev);
      gl.uniform2f(su.uBox, c.box[0], c.box[1]);
      gl.uniform4f(su.uPrint, c.print[0], c.print[1], c.print[2], c.print[3]);
      gl.uniform1f(su.uRot, c.rot);
      gl.uniform3f(su.uLamp, c.lamp[0], c.lamp[1], c.lamp[2]);
      gl.uniform1i(su.uTex, 0);
      gl.uniform1f(su.uDirect, canAcc ? 0 : 1);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
      if (canAcc && post) {
        gl.bindFramebuffer(gl.FRAMEBUFFER, null);
        gl.disable(gl.BLEND);
        gl.useProgram(post);
        gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, acc);
        gl.uniform1i(pu.uAcc, 1); gl.uniform2f(pu.uRes, W, H);
        gl.uniform1f(pu.uGrain, still ? 1 : s.frame % 64);
        gl.uniform2f(pu.uCentre, 0.45, c.wide ? 0.55 : 0.6);
        gl.drawArrays(gl.TRIANGLES, 0, 3);
      }
    };

    const go = (i: number, fromUser: boolean) => {
      const k = Math.max(0, Math.min(N - 1, i));
      if (fromUser) s.touched = true;
      s.target = k; s.want = k;
      if (k !== actRef.current) { actRef.current = k; setAct(k); }
      if (reduce) { s.sel = k; s.v = 0; s.cur = k; s.dev = 1; s.hot = 1; s.count = 0; }
      wake();
    };
    api.current = { go };

    const frame = (now: number) => {
      s.raf = 0;
      if (!s.visible) return;
      const dtRaw = s.last ? (now - s.last) / 1000 : 0.016;
      const dt = Math.min(0.033, dtRaw);
      s.last = now; s.frame++;
      if (!s.t0) { s.t0 = now; s.demoAt = now + 4200; }

      // the self-running demo: it opens on a finished print, holds, then walks the years until touched
      if (!still && !s.touched && now > s.demoAt) {
        const order = [3, 4, 5, 0, 1, 2];
        go(order[Math.floor((now - s.t0 - 4200) / 5200) % order.length] ?? START, false);
        s.demoAt = now + 5200;
      }

      let moving = false;
      if (!reduce) {
        const sub = 4, h = dt / sub;
        for (let k = 0; k < sub; k++) {
          // the strip has a little weight: it is pushed, coasts a hair past and settles
          const a = Math.max(-14, Math.min(14, 38 * (s.target - s.sel) - 9.5 * s.v));
          s.v += a * h; s.sel += s.v * h;
        }
        const far = Math.abs(s.target - s.sel);
        // the lamp under the glass dims while the strip is moving, then swells under the new frame
        const hotGoal = far < 0.04 && Math.abs(s.v) < 0.15 ? 1 : 0.15;
        s.hot += (hotGoal - s.hot) * (1 - Math.exp(-dt * (hotGoal > s.hot ? 3.2 : 9)));
        // the print: the old one clears to paper, then the new one comes up in the developer
        if (s.cur !== s.want) { s.dev = Math.max(0, s.dev - dt * 3.2); if (s.dev === 0) s.cur = s.want; }
        else if (s.hot > 0.5) s.dev = Math.min(1, s.dev + dt / 2.6);
        moving = Math.abs(s.v) > 0.002 || far > 0.0005 || Math.abs(s.hot - hotGoal) > 0.004 || s.dev < 1 || s.cur !== s.want;
        if (!moving) { s.sel = s.target; s.v = 0; }
      }
      if (!still) {
        s.sx += (s.px - s.sx) * (1 - Math.exp(-dt * 3)); s.sy += (s.py - s.sy) * (1 - Math.exp(-dt * 3));
        if (Math.abs(s.px - s.sx) > 0.002 || Math.abs(s.py - s.sy) > 0.002) moving = true;
      }

      const fast = Math.abs(s.v) > 0.25;
      if (s.fresh || !canAcc) { draw(1, 0); s.fresh = false; s.count = 1; }
      else if (moving) { draw(fast ? 0.7 : 0.35, 1 + (s.frame % 64)); s.count = 3; }
      else if (s.count < 48) { s.count++; draw(1 / s.count, s.count); }
      if (s.first) { s.first = false; setShown("gl"); }

      if (!still && dtRaw > 0.026) s.slow++; else s.slow = Math.max(0, s.slow - 2);
      if (s.slow > 45 && scale > 0.5) { scale *= 0.82; s.slow = 0; size(); }

      if (moving || s.count < 48 || (!still && !s.touched)) s.raf = requestAnimationFrame(frame);
    };
    const wake = () => { if (!s.raf && s.visible) { s.last = 0; s.raf = requestAnimationFrame(frame); } };

    size();
    placeTags(camera());
    const ro = new ResizeObserver(() => { size(); placeTags(camera()); wake(); });
    ro.observe(box);
    const io = new IntersectionObserver(([e]) => { s.visible = e.isIntersecting && !document.hidden; if (s.visible) wake(); }, { threshold: 0.05 });
    io.observe(box);
    const vis = () => { s.visible = !document.hidden; if (s.visible) wake(); };
    document.addEventListener("visibilitychange", vis);

    // A press on the strip brings that frame to the lamp.
    const down = (e: PointerEvent) => {
      if ((e.target as HTMLElement).closest("button")) return;
      const r = box.getBoundingClientRect(), c = camera();
      let best = -1, bd = 70;
      for (let i = 0; i < N; i++) {
        const [x, y] = project(c, [(i - s.sel) * PITCH, TOP, 0]);
        const d = Math.hypot(x - (e.clientX - r.left), (y - (e.clientY - r.top)) * 0.7);
        if (d < bd) { bd = d; best = i; }
      }
      if (best >= 0) go(best, true);
    };
    const move = (e: PointerEvent) => {
      if (still) return;
      const r = box.getBoundingClientRect();
      s.px = ((e.clientX - r.left) / r.width) * 2 - 1; s.py = ((e.clientY - r.top) / r.height) * 2 - 1;
      wake();
    };
    const leave = () => { s.px = 0; s.py = 0; wake(); };
    box.addEventListener("pointerdown", down);
    box.addEventListener("pointermove", move);
    box.addEventListener("pointerleave", leave);
    const lost = (e: Event) => { e.preventDefault(); setShown("still"); };
    cv.addEventListener("webglcontextlost", lost);
    wake();

    return () => {
      alive = false;
      cancelAnimationFrame(s.raf); ro.disconnect(); io.disconnect();
      document.removeEventListener("visibilitychange", vis);
      box.removeEventListener("pointerdown", down); box.removeEventListener("pointermove", move);
      box.removeEventListener("pointerleave", leave); cv.removeEventListener("webglcontextlost", lost);
      api.current = null;
    };
  }, []);

  const choose = (i: number) => {
    if (api.current) api.current.go(i, true); else { actRef.current = i; setAct(i); }
  };
  const keys = (e: React.KeyboardEvent, i: number) => {
    const next = e.key === "ArrowRight" || e.key === "ArrowDown" ? i + 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? i - 1 : e.key === "Home" ? 0 : e.key === "End" ? N - 1 : -9;
    if (next === -9) return;
    e.preventDefault();
    const k = Math.max(0, Math.min(N - 1, next));
    choose(k);
    tags.current[k]?.focus();
  };
  const y = years[act];
  const fallback = shown === "still";

  return (
    <section className="relative isolate overflow-hidden bg-[#0c0a08] text-[#f3ebdf]" style={{ fontFamily: FONT }}>
      <div ref={stage} className="relative mx-auto h-[800px] w-full max-w-[1600px] sm:h-[820px] lg:h-[740px]" style={{ background: "radial-gradient(70% 60% at 45% 30%, #2a221a 0%, #120e0a 60%, #0a0806 100%)" }}>
        <canvas ref={canvas} aria-hidden="true" className={`absolute inset-0 h-full w-full transition-opacity duration-700 motion-reduce:transition-none ${shown === "gl" ? "opacity-100" : "opacity-0"}`} />
        {fallback && <Still act={act} />}
        {fallback && <img src={y.photo} alt="" aria-hidden="true" className="absolute bottom-[12%] left-[9%] hidden h-[200px] w-[300px] rotate-[-3deg] border-[8px] border-[#e8e3da] object-cover shadow-2xl lg:block" style={{ backgroundColor: y.tone }} />}

        <p className="pointer-events-none absolute left-6 top-6 text-[11px] font-medium uppercase tracking-[0.32em] text-[#f3ebdf]/60 sm:left-10 lg:left-14 lg:top-10">
          {COMPANY}<span className="mx-2 text-[#f3ebdf]/30">/</span><span className="hidden sm:inline">{STRAP}</span><span className="sm:hidden">Our story</span>
        </p>

        <div aria-live="polite" className="pointer-events-none absolute inset-x-0 bottom-0 px-6 pb-8 sm:px-10 lg:left-auto lg:right-0 lg:w-[46%] lg:pb-16 lg:pr-14">
          {fallback && <img src={y.photo} alt="" className="mb-5 h-[150px] w-[225px] -rotate-2 border-[6px] border-[#e8e3da] object-cover shadow-2xl lg:hidden" style={{ backgroundColor: y.tone }} />}
          <p className="text-[86px] leading-[0.8] tracking-[-0.04em] text-[#f6e7cf] tabular-nums sm:text-[112px] lg:text-[136px]" style={{ fontFamily: SERIF }}>{y.year}</p>
          <h2 className="mt-4 text-[26px] font-semibold leading-[1.02] tracking-[-0.035em] sm:text-[32px] lg:mt-6 lg:text-[38px]">{y.title}</h2>
          <p className="mt-3 max-w-[27rem] text-[16px] leading-snug text-[#f3ebdf]/72 lg:text-[18px]" style={{ fontFamily: SERIF }}>{y.body}</p>
        </div>

        <ol aria-label="Years in our history" className={`pointer-events-none ${fallback ? "absolute inset-x-4 top-[calc(14%+186px)] flex justify-center gap-0 sm:gap-3" : "absolute inset-0"}`}>
          {years.map((it, i) => {
            const on = i === act;
            return (
              <li key={it.year}>
                <button
                  ref={(el) => { tags.current[i] = el; }}
                  type="button"
                  onClick={() => choose(i)}
                  onKeyDown={(e) => keys(e, i)}
                  aria-current={on ? "step" : undefined}
                  aria-label={`${it.year}: ${it.title}`}
                  tabIndex={on ? 0 : -1}
                  className={`pointer-events-auto flex min-h-[40px] min-w-[44px] items-center justify-center rounded-full px-1.5 text-[12px] sm:px-2.5 sm:text-[13px] font-semibold tabular-nums tracking-[0.06em] outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-[#f0b979] motion-reduce:transition-none ${fallback ? "relative" : "absolute left-0 top-0 opacity-0"} ${on ? "text-[#f6c48a]" : "text-[#f3ebdf]/50 hover:text-[#f3ebdf]"}`}
                  style={fallback ? { opacity: 1 } : undefined}
                >
                  <span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full transition-colors duration-300 ${on ? "bg-[#f6c48a]" : "bg-[#f3ebdf]/25"}`} />
                  {it.year}
                </button>
              </li>
            );
          })}
        </ol>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/ray-traced-light-box-history-timeline-with-a-sliding-film-negative-strip-and-a-print-that-develops-beside-it.json

When to use it

How to customize it

Accessibility

Questions

How are the negatives made from ordinary photographs?

The six photos are drawn once into a single atlas texture. Where the strip covers a frame, the shader takes the photo, inverts it and multiplies it by the orange base of colour film, then lets the light box shine through it. So bright skies become dense, dark corners turn clear, and the colours flip to their opposites as on real stock.

Why does the print go white before the next one appears?

It reads as a fresh sheet going into the developer. A value runs down to zero, the frame is swapped while the paper is blank, and then it climbs again over about two and a half seconds. Darker parts of the picture pass the threshold sooner, so the shadows show before the highlights.

Where do the soft shadows and the blurred reflections come from?

Each frame is one exposure added to a floating point buffer. Every exposure moves the lamp a little, tilts the varnish on the desk a little and shifts the lens, so the hard shadow behind the box and the sharp reflection of its glowing edge both soften as the exposures add up. It is the same idea as a path tracer, spread over time.

Do the photos need special hosting?

They must be served with cross-origin access allowed, because WebGL cannot read a picture from another site without it. The Pexels addresses in the demo already do this. If a picture cannot load, its frame and print show the flat `tone` colour instead of breaking.

How heavy is it?

The canvas is held to about 1.4 million traced pixels and lowers its own resolution if frames keep running long. The scene is solved with flat planes and a box rather than marching, so each pixel costs only a few texture reads. It stops drawing when the timeline scrolls out of view or the tab is hidden, and goes quiet once a picture has settled.

What does a browser without WebGL see?

A pale glowing panel with the strip drawn using CSS filters on the same photographs, the print as an ordinary image, and the year buttons in a row. Choosing a year still slides the strip and changes the story.

Related: Ray-Marched Oak Groove Stepper with a Steel Ball That Rolls to Turned Brass Posts in Raking Window Light, Knitted Scarf Project Timeline That Knits One Wool Stripe per Step as Each Ball of Yarn Runs Out, Light Table Slide Gallery with a Glass Loupe That Glides Between Mounted Transparencies.

More timelines and steppers or all free UI components. Start for free to have a full site built around it.

More in Timelines

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap