Sixteen Millimetre Film Projector Gallery with Turning Reels, a Dusty Beam and Stills That Flicker onto a Tripod Screen

A dark room with one machine running in it. On a walnut side table stands a sage green projector with a hammered enamel finish, glowing lamp vents, fluted knobs and a black lens barrel. Two aluminium reels turn above it at different speeds, because the full one moves less for every inch of film than the nearly empty one, and a strand of stock runs between them through two shaking loops and the gate. From the lens a cone of light crosses the room, tinted by whatever is on screen and full of drifting dust. It lands on a portable screen on a tripod, set at an angle, where the current photograph shows with a hot centre, dark corners, a faint scratch and the small jump of a worn print. Every few seconds the frame rolls up and the next still drops into place while the light spilling on the wall and floor changes colour with it. Below 700 pixels the scene is rearranged: the screen fills the top of the phone and the projector tilts up at it from underneath.

Code

import { useCallback, useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { useReducedMotion } from "framer-motion";

/** The stills on the reel, in running order. `tone` is shown until the picture arrives and tints the beam. */
const stills = [
  { title: "Still water", src: "https://images.pexels.com/photos/4998598/pexels-photo-4998598.jpeg?auto=compress&cs=tinysrgb&w=1000", alt: "Woman in a pale pink dress floating on her back in bright blue pool water", tone: "#2aa7c9" },
  { title: "Two lamps", src: "https://images.pexels.com/photos/12314821/pexels-photo-12314821.jpeg?auto=compress&cs=tinysrgb&w=1000", alt: "Young man lit teal from one side against a red wall", tone: "#b23a30" },
  { title: "Mile marker", src: "https://images.pexels.com/photos/7022880/pexels-photo-7022880.jpeg?auto=compress&cs=tinysrgb&w=1000", alt: "Woman in a white dress standing on an empty desert road under a pale sky", tone: "#a9b7c2" },
  { title: "Red room", src: "https://images.pexels.com/photos/9895319/pexels-photo-9895319.jpeg?auto=compress&cs=tinysrgb&w=1000", alt: "Profile of a person in silhouette against a deep red wall", tone: "#b01a22" },
  { title: "Last tram", src: "https://images.pexels.com/photos/36806859/pexels-photo-36806859.jpeg?auto=compress&cs=tinysrgb&w=1000", alt: "Woman with hair blown across her face on a dark street under rows of ceiling lights", tone: "#2a6672" },
];
const N = stills.length;
const HOLD_MS = 3400; // how long each still stays in the gate
const ENAMEL = ["#8a9d91", "#4a584f", "#1c2420"]; // the projector's paint, light to dark
const SPEED = 150; // film speed in design pixels a second; the reels turn to match

const S = { x: 96, y: -212 }, T = { x: -126, y: -196 }, REEL = 98, HUB = 30; // feed reel, take-up reel
const LENS = { x: 218, y: -24 };

const layoutFor = (narrow: boolean) =>
  narrow
    ? { W: 520, H: 850, proj: { x: 205, y: 596, sc: 0.58, tilt: -48 }, scr: { xL: 30, xR: 490, tl: 44, bl: 338, tr: 44, br: 338 }, table: { x0: 70, x1: 410 }, floor: 772, stand: false }
    : { W: 1200, H: 700, proj: { x: 262, y: 392, sc: 1, tilt: -2 }, scr: { xL: 668, xR: 1142, tl: 150, bl: 446, tr: 96, br: 486 }, table: { x0: 62, x1: 470 }, floor: 628, stand: true };

type Ctx = CanvasRenderingContext2D;
const rng = (seed: number) => () => {
  seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const rr = (c: Ctx, x: number, y: number, w: number, h: number, r: number) => {
  c.beginPath(); c.moveTo(x + r, y); c.arcTo(x + w, y, x + w, y + h, r); c.arcTo(x + w, y + h, x, y + h, r); c.arcTo(x, y + h, x, y, r); c.arcTo(x, y, x + w, y, r); c.closePath();
};
const chrome = (c: Ctx, x0: number, y0: number, x1: number, y1: number) => {
  const g = c.createLinearGradient(x0, y0, x1, y1);
  g.addColorStop(0, "#f4f5f7"); g.addColorStop(0.22, "#8f9299"); g.addColorStop(0.45, "#e2e4e8"); g.addColorStop(0.7, "#4b4e54"); g.addColorStop(1, "#9da0a6");
  return g;
};
const mix = (hex: string, t: number) => {
  const n = parseInt(hex.slice(1), 16), a = [n >> 16, (n >> 8) & 255, n & 255], w = [255, 236, 204];
  return a.map((v, i) => Math.round(v * t + w[i] * (1 - t))).join(",");
};

export default function FilmProjectorGallery() {
  const reduce = useReducedMotion();
  const wrapRef = useRef<HTMLDivElement>(null);
  const cvRef = useRef<HTMLCanvasElement>(null);
  const [active, setActive] = useState(0);
  const ctl = useRef({ target: 0, paused: false, nextAuto: 0, redraw: () => {} });

  const step = useCallback((dir: number) => {
    const c = ctl.current;
    c.target += dir; c.nextAuto = performance.now() + HOLD_MS * 2;
    setActive(((c.target % N) + N) % N);
    c.redraw();
  }, []);

  useEffect(() => {
    const wrap = wrapRef.current, cv = cvRef.current;
    if (!wrap || !cv) return;
    const ctx = cv.getContext("2d");
    if (!ctx) return;
    const c0 = ctl.current;
    let L = layoutFor(false), W = 0, k = 1, ox = 0, box = { x: 0, y: 0 }, raf = 0, visible = true, last = performance.now();
    const body = document.createElement("canvas"), room = document.createElement("canvas");
    // the photographs live in the page as real images with alt text; the canvas only borrows them
    const imgs = Array.from(wrap.querySelectorAll<HTMLImageElement>("[data-still]"));
    const loaded = () => c0.redraw();
    imgs.forEach((im) => im.addEventListener("load", loaded));
    const st = { shown: 0, from: 0, cut: 1, aS: 0.3, aT: 1.1, p: 0, scratchX: 0.3, scratchT: 0 };
    const rnd = rng(9);
    const motes = Array.from({ length: 70 }, () => ({ u: rnd(), v: rnd() * 2 - 1, du: (rnd() - 0.5) * 0.012, dv: (rnd() - 0.5) * 0.05, r: 0.6 + rnd() * 1.3, ph: rnd() * 6.3 }));
    c0.nextAuto = last + 3700;

    /** A point on the projector, in stage coordinates. */
    const at = (x: number, y: number) => {
      const a = (L.proj.tilt * Math.PI) / 180, cs = Math.cos(a), sn = Math.sin(a);
      return { x: L.proj.x + (x * cs - y * sn) * L.proj.sc, y: L.proj.y + (x * sn + y * cs) * L.proj.sc };
    };
    const onProj = (c: Ctx) => { c.translate(L.proj.x, L.proj.y); c.rotate((L.proj.tilt * Math.PI) / 180); c.scale(L.proj.sc, L.proj.sc); };

    const build = () => {
      grainAll.width = cv.width + 192; grainAll.height = cv.height + 192;
      const ga = grainAll.getContext("2d")!, gp = ga.createPattern(grain, "repeat");
      if (gp) { ga.fillStyle = gp; ga.fillRect(0, 0, grainAll.width, grainAll.height); }
      // The room: wall, floor, the side table and the screen's stand.
      room.width = cv.width; room.height = cv.height;
      const b = room.getContext("2d")!;
      b.setTransform(1, 0, 0, 1, 0, 0);
      b.fillStyle = "#0c0b0a"; b.fillRect(0, 0, room.width, room.height);
      b.setTransform(k, 0, 0, k, ox * k, 0);
      const r = rng(3);
      const fl = b.createLinearGradient(0, L.floor, 0, L.H);
      fl.addColorStop(0, "#1a1714"); fl.addColorStop(1, "#090807");
      b.fillStyle = fl; b.fillRect(-2000, L.floor, 5200, L.H - L.floor + 2);
      b.fillStyle = "rgba(255,240,220,0.05)"; b.fillRect(-2000, L.floor, 5200, 1);
      const q = L.scr, mx = (q.xL + q.xR) / 2, by = (q.bl + q.br) / 2;
      // tripod and pole
      if (L.stand) {
        b.strokeStyle = chrome(b, mx - 5, 0, mx + 5, 0); b.lineWidth = 7; b.lineCap = "round";
        b.beginPath(); b.moveTo(mx, Math.min(q.tl, q.tr) - 16); b.lineTo(mx, L.floor - 34); b.stroke();
        b.lineWidth = 5; b.strokeStyle = "#2a2b2e";
        for (const dx of [-88, 0, 96]) { b.beginPath(); b.moveTo(mx, L.floor - 70); b.lineTo(mx + dx, L.floor - (dx ? 0 : -8)); b.stroke(); }
        b.fillStyle = "#16171a"; rr(b, mx - 9, by + 22, 18, 26, 4); b.fill();
      }
      if (L.stand) {
        // side table
        const p0 = at(-166, 122), p1 = at(166, 122), ty = Math.max(p0.y, p1.y) + 2;
        const wd = b.createLinearGradient(0, ty, 0, ty + 16);
        wd.addColorStop(0, "#8a5a36"); wd.addColorStop(0.2, "#5d3a22"); wd.addColorStop(1, "#2a190e");
        b.fillStyle = "rgba(0,0,0,0.55)"; b.beginPath(); b.ellipse((L.table.x0 + L.table.x1) / 2, L.floor + 6, (L.table.x1 - L.table.x0) / 2, 9, 0, 0, Math.PI * 2); b.fill();
        b.fillStyle = "#1b120b";
        for (const [lx, d] of [[L.table.x0 + 36, -14], [L.table.x1 - 36, 14]]) { b.beginPath(); b.moveTo(lx - 7, ty + 12); b.lineTo(lx + 7, ty + 12); b.lineTo(lx + d + 3, L.floor + 4); b.lineTo(lx + d - 3, L.floor + 4); b.closePath(); b.fill(); }
        rr(b, L.table.x0, ty, L.table.x1 - L.table.x0, 16, 4); b.fillStyle = wd; b.fill();
        for (let i = 0; i < 60; i++) { b.fillStyle = `rgba(${r() > 0.5 ? "255,220,180" : "0,0,0"},${0.03 + r() * 0.05})`; b.fillRect(L.table.x0 + 4 + r() * (L.table.x1 - L.table.x0 - 60), ty + 2 + r() * 12, 20 + r() * 50, 0.8); }
        b.fillStyle = "rgba(255,225,190,0.35)"; b.fillRect(L.table.x0 + 4, ty + 0.5, L.table.x1 - L.table.x0 - 8, 1);
        // front elevating leg, so the tilted body still stands on the table
        b.lineCap = "butt"; b.strokeStyle = "#5c5f66"; b.lineWidth = 4 * L.proj.sc + 1; b.beginPath(); b.moveTo(at(140, 110).x, at(140, 110).y); b.lineTo(at(140, 110).x, ty); b.stroke();
        b.fillStyle = "rgba(0,0,0,0.5)"; b.beginPath(); b.ellipse((at(-150, 122).x + at(150, 122).x) / 2, ty + 1, 170 * L.proj.sc, 5, 0, 0, Math.PI * 2); b.fill();
        b.fillStyle = "#111"; b.fillRect(at(140, 110).x - 9, ty - 4, 18, 4); b.fillRect(at(-140, 122).x - 9, ty - 4, 18, 4);
      } else {
        // no room for a table on a phone: the projector rides a tilting head on its own tripod
        const hd = at(0, 122);
        b.lineCap = "round"; b.lineWidth = 5; b.strokeStyle = "#2a2b2e";
        for (const dx of [-74, 0, 80]) { b.beginPath(); b.moveTo(hd.x, L.floor - 62); b.lineTo(hd.x + dx, L.floor - (dx ? 0 : -8)); b.stroke(); }
        b.strokeStyle = chrome(b, hd.x - 5, 0, hd.x + 5, 0); b.lineWidth = 7; b.beginPath(); b.moveTo(hd.x, hd.y + 6); b.lineTo(hd.x, L.floor - 30); b.stroke();
        b.fillStyle = "#141516"; b.beginPath(); b.arc(hd.x, hd.y + 4, 11, 0, Math.PI * 2); b.fill();
      }

      // The projector body: everything on it that does not turn.
      // (its layer is only as big as the machine, so copying it each frame stays cheap)
      const R = 372 * L.proj.sc;
      box = { x: L.proj.x - R, y: L.proj.y - R };
      body.width = body.height = Math.ceil(R * 2 * k);
      const c = body.getContext("2d")!;
      c.setTransform(k, 0, 0, k, -box.x * k, -box.y * k);
      onProj(c);
      const enamel = (x0: number, y0: number, x1: number, y1: number) => { const g = c.createLinearGradient(x0, y0, x1, y1); g.addColorStop(0, ENAMEL[0]); g.addColorStop(0.45, ENAMEL[1]); g.addColorStop(1, ENAMEL[2]); return g; };
      const hammer = (x: number, y: number, w: number, h: number, n: number) => { for (let i = 0; i < n; i++) { c.fillStyle = `rgba(${r() > 0.5 ? "255,255,255" : "0,0,0"},${0.03 + r() * 0.06})`; c.beginPath(); c.arc(x + r() * w, y + r() * h, 0.5 + r() * 1.1, 0, Math.PI * 2); c.fill(); } };
      // reel arms
      for (const [a, bb] of [[{ x: 34, y: -60 }, S], [{ x: -74, y: -60 }, T]] as const) {
        const dx = bb.x - a.x, dy = bb.y - a.y, len = Math.hypot(dx, dy), ang = Math.atan2(dy, dx);
        c.save(); c.translate(a.x, a.y); c.rotate(ang);
        c.shadowColor = "rgba(0,0,0,0.6)"; c.shadowBlur = 10 * k; c.shadowOffsetY = 4 * k;
        c.beginPath(); c.moveTo(-10, -19); c.lineTo(len, -12); c.arc(len, 0, 12, -Math.PI / 2, Math.PI / 2); c.lineTo(-10, 19); c.closePath();
        c.fillStyle = enamel(0, -19, 0, 19); c.fill(); c.shadowColor = "transparent";
        c.save(); c.clip(); hammer(-10, -19, len + 22, 38, 260); c.restore();
        c.strokeStyle = "rgba(255,255,255,0.22)"; c.lineWidth = 1; c.beginPath(); c.moveTo(-8, -18); c.lineTo(len, -11.2); c.stroke();
        c.restore();
      }
      // lamp house with glowing vents
      c.save(); c.shadowColor = "rgba(0,0,0,0.7)"; c.shadowBlur = 24 * k; c.shadowOffsetY = 12 * k;
      rr(c, -152, -72, 304, 168, 26); c.fillStyle = enamel(0, -72, 0, 96); c.fill(); c.restore();
      c.save(); rr(c, -152, -72, 304, 168, 26); c.clip();
      hammer(-152, -72, 304, 168, 2600);
      const top = c.createLinearGradient(0, -72, 0, -30); top.addColorStop(0, "rgba(255,255,255,0.42)"); top.addColorStop(0.25, "rgba(255,255,255,0.12)"); top.addColorStop(1, "rgba(255,255,255,0)"); c.fillStyle = top; c.fillRect(-152, -72, 304, 42);
      const gloss = c.createLinearGradient(0, -34, 0, 4); gloss.addColorStop(0, "rgba(255,255,255,0)"); gloss.addColorStop(0.5, "rgba(255,255,255,0.1)"); gloss.addColorStop(1, "rgba(255,255,255,0)"); c.fillStyle = gloss; c.fillRect(-152, -34, 304, 38);
      const low = c.createLinearGradient(0, 40, 0, 96); low.addColorStop(0, "rgba(0,0,0,0)"); low.addColorStop(1, "rgba(0,0,0,0.45)"); c.fillStyle = low; c.fillRect(-152, 40, 304, 56);
      const side = c.createLinearGradient(60, 0, 152, 0); side.addColorStop(0, "rgba(255,236,204,0)"); side.addColorStop(1, "rgba(255,236,204,0.26)"); c.fillStyle = side; c.fillRect(60, -72, 92, 168);
      c.restore();
      c.strokeStyle = "rgba(0,0,0,0.5)"; c.lineWidth = 1.5; rr(c, -152, -72, 304, 168, 26); c.stroke();
      for (let i = 0; i < 7; i++) {
        const vx = -132 + i * 11;
        rr(c, vx, -44, 5, 74, 2.5); c.fillStyle = "#0a0605"; c.fill();
        const gl = c.createLinearGradient(0, -44, 0, 30); gl.addColorStop(0, "rgba(255,150,60,0)"); gl.addColorStop(0.55, "rgba(255,170,70,0.95)"); gl.addColorStop(1, "rgba(255,120,40,0.1)");
        rr(c, vx + 1, -42, 3, 70, 1.5); c.fillStyle = gl; c.fill();
      }
      const warm = c.createRadialGradient(-100, -6, 4, -100, -6, 80); warm.addColorStop(0, "rgba(255,150,60,0.22)"); warm.addColorStop(1, "rgba(255,150,60,0)"); c.fillStyle = warm; c.fillRect(-180, -90, 160, 170);
      // film channel: the black plate the film threads across
      rr(c, 86, -98, 58, 150, 10); c.fillStyle = "#101112"; c.fill();
      c.strokeStyle = "rgba(255,255,255,0.14)"; c.lineWidth = 1; rr(c, 86.5, -97.5, 57, 149, 10); c.stroke();
      // chrome waist band, base and name plate
      c.fillStyle = chrome(c, 0, 46, 0, 51); c.fillRect(-152, 46, 238, 4);
      rr(c, -166, 92, 332, 30, 9); c.fillStyle = "#0f1011"; c.fill();
      c.fillStyle = "rgba(255,255,255,0.12)"; c.fillRect(-158, 93, 316, 1);
      rr(c, -44, 71, 66, 15, 3); c.fillStyle = chrome(c, 0, 71, 0, 86); c.fill();
      for (const [hx, hy] of [[-138, -58], [72, -58], [-138, 82], [72, 82]]) { c.beginPath(); c.arc(hx, hy, 3.4, 0, Math.PI * 2); c.fillStyle = chrome(c, hx - 3, hy - 3, hx + 3, hy + 3); c.fill(); c.strokeStyle = "rgba(0,0,0,0.7)"; c.lineWidth = 0.9; c.beginPath(); c.moveTo(hx - 2.2, hy - 1.2); c.lineTo(hx + 2.2, hy + 1.2); c.stroke(); }
      c.fillStyle = "#1d1e20"; c.font = "700 8px Helvetica, Arial, sans-serif"; c.textAlign = "center"; c.textBaseline = "middle"; c.fillText("16 MM  24 FPS", -11, 79);
      // knobs
      for (const [kx, ky, kr] of [[-36, 8, 19], [22, 14, 12]]) {
        c.save(); c.shadowColor = "rgba(0,0,0,0.7)"; c.shadowBlur = 6 * k; c.shadowOffsetY = 3 * k;
        c.beginPath(); c.arc(kx, ky, kr, 0, Math.PI * 2); c.fillStyle = "#151617"; c.fill(); c.restore();
        for (let i = 0; i < 28; i++) { const a = (i / 28) * Math.PI * 2; c.strokeStyle = i % 2 ? "rgba(255,255,255,0.22)" : "rgba(0,0,0,0.6)"; c.lineWidth = 1; c.beginPath(); c.moveTo(kx + Math.cos(a) * (kr - 4), ky + Math.sin(a) * (kr - 4)); c.lineTo(kx + Math.cos(a) * kr, ky + Math.sin(a) * kr); c.stroke(); }
        c.beginPath(); c.arc(kx, ky, kr - 5, 0, Math.PI * 2); c.fillStyle = chrome(c, kx - kr, ky - kr, kx + kr, ky + kr); c.fill();
        c.strokeStyle = "#1a1b1d"; c.lineWidth = 2; c.beginPath(); c.moveTo(kx, ky); c.lineTo(kx + (kr - 7) * 0.6, ky - (kr - 7) * 0.8); c.stroke();
      }
      // lens barrel
      const lg = c.createLinearGradient(0, -50, 0, 2);
      lg.addColorStop(0, "#5a5c61"); lg.addColorStop(0.18, "#1c1d1f"); lg.addColorStop(0.5, "#0b0b0c"); lg.addColorStop(0.82, "#232426"); lg.addColorStop(1, "#050505");
      c.fillStyle = lg; c.fillRect(142, -48, 62, 48);
      for (let i = 0; i < 14; i++) { c.fillStyle = i % 2 ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.5)"; c.fillRect(160 + i * 2, -48, 1, 48); }
      c.fillStyle = chrome(c, 0, -52, 0, 4); c.fillRect(138, -52, 8, 56); c.fillRect(202, -51, 16, 54);
      c.strokeStyle = "rgba(0,0,0,0.6)"; c.lineWidth = 1; c.strokeRect(202.5, -50.5, 15, 53);
      // the only light in the room comes from the screen side: the back of the machine falls into shadow
      c.globalCompositeOperation = "source-atop";
      const dk = c.createLinearGradient(-170, 0, 150, 0); dk.addColorStop(0, "rgba(4,3,2,0.62)"); dk.addColorStop(0.6, "rgba(4,3,2,0.18)"); dk.addColorStop(1, "rgba(4,3,2,0)");
      c.fillStyle = dk; c.fillRect(-260, -330, 520, 480);
      c.globalCompositeOperation = "source-over";
    };

    // fine grain over the whole picture: it hides gradient banding and moves a little each frame
    const grain = document.createElement("canvas"); grain.width = grain.height = 192;
    { const g = grain.getContext("2d")!, gr = rng(41); for (let i = 0; i < 9000; i++) { g.fillStyle = `rgba(${gr() > 0.5 ? "255,240,220" : "0,0,0"},${0.012 + gr() * 0.03})`; g.fillRect(gr() * 192, gr() * 192, 1, 1); } }
    const grainAll = document.createElement("canvas"); // the tile laid out once over the whole stage, so a frame only copies it

    const reel = (o: { x: number; y: number }, ang: number, fill: number) => {
      const rp = HUB + 6 + fill * (REEL - 16 - HUB);
      ctx.save(); ctx.translate(o.x, o.y);
      ctx.save(); ctx.shadowColor = "rgba(0,0,0,0.65)"; ctx.shadowBlur = 16 * k; ctx.shadowOffsetX = -4 * k; ctx.shadowOffsetY = 8 * k;
      ctx.beginPath(); ctx.arc(0, 0, REEL, 0, Math.PI * 2); ctx.fillStyle = "#0b0b0c"; ctx.fill(); ctx.restore();
      // the wound film: dark stock with a sheen that stays put while the reel turns
      const fg = ctx.createRadialGradient(0, 0, HUB, 0, 0, rp);
      fg.addColorStop(0, "#1c120c"); fg.addColorStop(0.85, "#3a2618"); fg.addColorStop(1, "#5a3c26");
      ctx.beginPath(); ctx.arc(0, 0, rp, 0, Math.PI * 2); ctx.fillStyle = fg; ctx.fill();
      ctx.lineWidth = 0.7;
      for (let q = HUB + 4; q < rp; q += 3.2) { ctx.strokeStyle = `rgba(255,214,170,${0.05 + ((q * 7) % 5) * 0.012})`; ctx.beginPath(); ctx.arc(0, 0, q, 0, Math.PI * 2); ctx.stroke(); }
      for (const a of [-0.9, 2.25]) { ctx.beginPath(); ctx.moveTo(0, 0); ctx.arc(0, 0, rp, a - 0.28, a + 0.28); ctx.closePath(); const sg = ctx.createRadialGradient(0, 0, HUB, 0, 0, rp); sg.addColorStop(0, "rgba(255,225,190,0)"); sg.addColorStop(1, "rgba(255,225,190,0.22)"); ctx.fillStyle = sg; ctx.fill(); }
      // the flange: an aluminium disc with four windows
      ctx.save(); ctx.rotate(ang);
      ctx.beginPath(); ctx.arc(0, 0, REEL, 0, Math.PI * 2);
      for (let i = 0; i < 4; i++) { const a0 = (i * Math.PI) / 2 + 0.3, a1 = ((i + 1) * Math.PI) / 2 - 0.3; ctx.moveTo(Math.cos(a0) * (HUB + 10), Math.sin(a0) * (HUB + 10)); ctx.arc(0, 0, REEL - 15, a0, a1); ctx.arc(0, 0, HUB + 10, a1, a0, true); ctx.closePath(); }
      ctx.rotate(-ang);
      let mg: CanvasGradient;
      if (typeof ctx.createConicGradient === "function") {
        mg = ctx.createConicGradient(0.5, 0, 0);
        for (const [p, col] of [[0, "#c9ccd1"], [0.08, "#f6f7f9"], [0.2, "#8e9198"], [0.33, "#d5d8dc"], [0.45, "#6a6d74"], [0.58, "#eceef1"], [0.7, "#84878e"], [0.85, "#b9bcc2"], [1, "#c9ccd1"]] as [number, string][]) mg.addColorStop(p, col);
      } else mg = chrome(ctx, -REEL, -REEL, REEL, REEL);
      ctx.fillStyle = mg; ctx.globalAlpha = 0.97; ctx.fill("evenodd"); ctx.globalAlpha = 1;
      ctx.rotate(ang);
      ctx.strokeStyle = "rgba(255,255,255,0.55)"; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.arc(0, 0, REEL - 0.8, 0, Math.PI * 2); ctx.stroke();
      ctx.strokeStyle = "rgba(0,0,0,0.4)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(0, 0, REEL - 5, 0, Math.PI * 2); ctx.stroke();
      for (let i = 0; i < 4; i++) { const a0 = (i * Math.PI) / 2 + 0.3, a1 = ((i + 1) * Math.PI) / 2 - 0.3; ctx.strokeStyle = "rgba(0,0,0,0.5)"; ctx.beginPath(); ctx.arc(0, 0, REEL - 15, a0, a1); ctx.stroke(); ctx.strokeStyle = "rgba(255,255,255,0.4)"; ctx.beginPath(); ctx.arc(0, 0, HUB + 10, a0, a1); ctx.stroke(); }
      // hub with three drive holes and a keyed spindle
      ctx.beginPath(); ctx.arc(0, 0, HUB - 6, 0, Math.PI * 2); ctx.fillStyle = "#17181a"; ctx.fill();
      ctx.fillStyle = "#050505"; for (let i = 0; i < 3; i++) { const a = (i * Math.PI * 2) / 3; ctx.beginPath(); ctx.arc(Math.cos(a) * 14, Math.sin(a) * 14, 4.2, 0, Math.PI * 2); ctx.fill(); }
      ctx.restore();
      ctx.beginPath(); ctx.arc(0, 0, 7.5, 0, Math.PI * 2); ctx.fillStyle = chrome(ctx, -7, -7, 7, 7); ctx.fill();
      ctx.save(); ctx.rotate(ang); ctx.fillStyle = "#0a0a0a"; ctx.fillRect(-2.6, -2.6, 5.2, 5.2); ctx.restore();
      // shade the side away from the screen and catch its glow on the near rim
      const shd = ctx.createLinearGradient(-REEL, REEL * 0.4, REEL, -REEL * 0.4); shd.addColorStop(0, "rgba(4,3,2,0.6)"); shd.addColorStop(0.55, "rgba(4,3,2,0.12)"); shd.addColorStop(1, "rgba(4,3,2,0)");
      ctx.beginPath(); ctx.arc(0, 0, REEL + 0.5, 0, Math.PI * 2); ctx.fillStyle = shd; ctx.fill();
      ctx.strokeStyle = "rgba(255,236,204,0.55)"; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.arc(0, 0, REEL - 1, -0.9, 0.5); ctx.stroke();
      ctx.restore();
      return rp;
    };

    const draw = (now: number) => {
      const q = L.scr, live = !reduce;
      const fl = live ? 0.9 + 0.1 * Math.sin(now / 41) * Math.sin(now / 173) - (Math.sin(now / 97) > 0.96 ? 0.12 : 0) : 1;
      const tone = stills[((st.shown % N) + N) % N].tone;
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.drawImage(room, 0, 0);
      ctx.setTransform(k, 0, 0, k, ox * k, 0);
      const mx = (q.xL + q.xR) / 2, my = (q.tl + q.tr + q.bl + q.br) / 4;
      // light spilling off the screen onto the wall and floor
      ctx.globalCompositeOperation = "lighter";
      const sp = ctx.createRadialGradient(mx, my, 60, mx, my, (q.xR - q.xL) * 1.05);
      sp.addColorStop(0, `rgba(${mix(tone, 0.7)},${0.2 * fl})`); sp.addColorStop(1, `rgba(${mix(tone, 0.7)},0)`);
      ctx.fillStyle = sp; ctx.fillRect(mx - 700, my - 600, 1400, 1200);
      ctx.save(); ctx.translate(mx, L.floor + 16); ctx.scale(1, 0.09);
      const fr = ctx.createRadialGradient(0, 0, 10, 0, 0, (q.xR - q.xL) * 0.8); fr.addColorStop(0, `rgba(${mix(tone, 0.7)},${0.3 * fl})`); fr.addColorStop(1, `rgba(${mix(tone, 0.7)},0)`);
      ctx.fillStyle = fr; ctx.beginPath(); ctx.arc(0, 0, (q.xR - q.xL) * 0.8, 0, Math.PI * 2); ctx.fill(); ctx.restore();
      // the beam
      const lens = at(LENS.x, LENS.y);
      const bg = ctx.createLinearGradient(lens.x, lens.y, mx, my);
      bg.addColorStop(0, `rgba(${mix(tone, 0.25)},${0.62 * fl})`); bg.addColorStop(0.3, `rgba(${mix(tone, 0.4)},${0.3 * fl})`); bg.addColorStop(1, `rgba(${mix(tone, 0.55)},${0.13 * fl})`);
      ctx.fillStyle = bg;
      ctx.beginPath(); ctx.moveTo(lens.x, lens.y); ctx.lineTo(q.xL, q.tl); ctx.lineTo(q.xR, q.tr); ctx.lineTo(q.xR, q.br); ctx.lineTo(q.xL, q.bl); ctx.closePath(); ctx.fill();
      for (let i = 0; i < 6; i++) {
        const f = (i + 0.5) / 6 + (live ? Math.sin(now / 1900 + i * 2.1) * 0.05 : 0), w = 0.03 + (i % 3) * 0.012;
        const ax = q.xL + (q.xR - q.xL) * (i % 2 ? 0.2 : 0.75), y0 = q.tl + (q.bl - q.tl) * (f - w), y1 = q.tl + (q.bl - q.tl) * (f + w);
        ctx.fillStyle = `rgba(${mix(tone, 0.3)},${0.05 * fl})`;
        ctx.beginPath(); ctx.moveTo(lens.x, lens.y); ctx.lineTo(ax, y0); ctx.lineTo(ax, y1); ctx.closePath(); ctx.fill();
      }
      for (const m of motes) {
        const tx = q.xL + (q.xR - q.xL) * 0.5, ty = (q.tl + q.bl) / 2 + ((q.bl - q.tl) / 2) * m.v * 0.9;
        const x = lens.x + (tx - lens.x) * m.u, y = lens.y + (ty - lens.y) * m.u;
        const a = (0.25 + 0.35 * Math.sin(now / 700 + m.ph)) * (1 - m.u * 0.6) * fl;
        if (a > 0) { ctx.fillStyle = `rgba(255,244,222,${a.toFixed(3)})`; ctx.beginPath(); ctx.arc(x, y, m.r, 0, Math.PI * 2); ctx.fill(); }
      }
      ctx.globalCompositeOperation = "source-over";
      // the screen: case tube, black masking, then the picture cut into thin upright slices to follow the slope
      const quad = (inset: number) => { ctx.beginPath(); ctx.moveTo(q.xL + inset, q.tl + inset); ctx.lineTo(q.xR - inset, q.tr + inset); ctx.lineTo(q.xR - inset, q.br - inset); ctx.lineTo(q.xL + inset, q.bl - inset); ctx.closePath(); };
      ctx.save(); ctx.shadowColor = "rgba(0,0,0,0.8)"; ctx.shadowBlur = 30 * k; ctx.shadowOffsetY = 14 * k; quad(-7); ctx.fillStyle = "#070707"; ctx.fill(); ctx.restore();
      ctx.strokeStyle = "rgba(255,255,255,0.13)"; ctx.lineWidth = 1; quad(-6.5); ctx.stroke();
      ctx.lineCap = "round"; ctx.lineWidth = 15; ctx.strokeStyle = "#0d0d0e"; ctx.beginPath(); ctx.moveTo(q.xL - 16, q.bl + 13); ctx.lineTo(q.xR + 16, q.br + 13); ctx.stroke();
      ctx.lineWidth = 2; ctx.strokeStyle = "rgba(255,255,255,0.2)"; ctx.beginPath(); ctx.moveTo(q.xL - 14, q.bl + 8.5); ctx.lineTo(q.xR + 14, q.br + 8.5); ctx.stroke();
      ctx.save(); quad(5); ctx.clip();
      const frame = (idx: number, shift: number) => {
        const i = ((idx % N) + N) % N, im = imgs[i], ok = im.complete && im.naturalWidth > 0;
        const SL = 72, sw = q.xR - q.xL, asp = sw / (((q.bl - q.tl) + (q.br - q.tr)) / 2);
        let cw = 1, ch = 1, cx0 = 0, cy0 = 0;
        if (ok) { const ia = im.naturalWidth / im.naturalHeight; if (ia > asp) { ch = im.naturalHeight; cw = ch * asp; cx0 = (im.naturalWidth - cw) / 2; } else { cw = im.naturalWidth; ch = cw / asp; cy0 = (im.naturalHeight - ch) * 0.4; } }
        for (let n = 0; n < SL; n++) {
          const t0 = n / SL, x = q.xL + sw * t0, y0 = q.tl + (q.tr - q.tl) * t0, h = q.bl + (q.br - q.bl) * t0 - y0, y = y0 + shift * (h + 10);
          if (ok) ctx.drawImage(im, cx0 + cw * t0, cy0, cw / SL, ch, x, y, sw / SL + 0.7, h);
          else { ctx.fillStyle = stills[i].tone; ctx.fillRect(x, y, sw / SL + 0.7, h); }
        }
      };
      const wv = live ? Math.sin(now / 53) * 0.7 + Math.sin(now / 31) * 0.4 : 0;
      ctx.translate(wv * 0.5, wv);
      if (st.cut < 1) { const e = st.cut * st.cut * (3 - 2 * st.cut); frame(st.from, -e); frame(st.shown, 1 - e); } else frame(st.shown, 0);
      ctx.translate(-wv * 0.5, -wv);
      // what the lamp and the print add: hot centre, dark corners, a tramline scratch, specks
      const vg = ctx.createRadialGradient(mx, my, 40, mx, my, (q.xR - q.xL) * 0.72);
      vg.addColorStop(0, "rgba(255,240,214,0.1)"); vg.addColorStop(0.55, "rgba(0,0,0,0)"); vg.addColorStop(1, "rgba(0,0,0,0.55)");
      ctx.fillStyle = vg; ctx.fillRect(q.xL, 0, q.xR - q.xL, L.H);
      ctx.fillStyle = `rgba(6,4,2,${(1 - fl) * 1.3})`; ctx.fillRect(q.xL, 0, q.xR - q.xL, L.H);
      if (live) {
        ctx.fillStyle = "rgba(255,250,235,0.11)"; ctx.fillRect(q.xL + (q.xR - q.xL) * st.scratchX + Math.sin(now / 60), 0, 0.8, L.H);
        const sr = rng(Math.floor(now / 42));
        for (let i = 0; i < 4; i++) { ctx.fillStyle = `rgba(10,6,2,${0.3 + sr() * 0.4})`; ctx.beginPath(); ctx.ellipse(q.xL + sr() * (q.xR - q.xL), Math.min(q.tl, q.tr) + sr() * (Math.max(q.bl, q.br) - Math.min(q.tl, q.tr)), 0.6 + sr() * 1.6, 0.6 + sr() * 2.4, sr() * 3, 0, Math.PI * 2); ctx.fill(); }
      }
      ctx.restore();
      // projector
      ctx.drawImage(body, box.x, box.y, body.width / k, body.height / k);
      ctx.save(); onProj(ctx);
      const sFill = 0.86 - 0.62 * st.p, tFill = 0.16 + 0.62 * st.p;
      const rS = reel(S, st.aS, sFill), rT = reel(T, -st.aT, tFill);
      // film path: off the feed reel, round the top sprocket, a loop, through the gate, a loop, and back to the take-up
      const jit = live ? Math.sin(now / 21) * 1.6 : 0;
      const path = () => {
        ctx.beginPath(); ctx.moveTo(S.x + rS, S.y);
        ctx.lineTo(126, -92); ctx.quadraticCurveTo(98 - jit, -78, 110, -60 - jit * 0.4); ctx.lineTo(132, -46); ctx.lineTo(132, -4);
        ctx.quadraticCurveTo(94 + jit, 14, 108, 34); ctx.quadraticCurveTo(96, 60, 60, 60); ctx.lineTo(-150, 60); ctx.quadraticCurveTo(-164, 60, -164, 44); ctx.lineTo(T.x - rT, T.y);
      };
      ctx.lineJoin = "round"; ctx.lineCap = "round";
      ctx.strokeStyle = "rgba(0,0,0,0.5)"; ctx.lineWidth = 6; path(); ctx.stroke();
      ctx.strokeStyle = "#3b2818"; ctx.lineWidth = 3.4; path(); ctx.stroke();
      ctx.strokeStyle = "rgba(255,214,170,0.3)"; ctx.lineWidth = 0.9; path(); ctx.stroke();
      for (const [sx, sy, sr] of [[122, -84, 9], [118, 28, 9], [-154, 52, 8]]) {
        ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI * 2); ctx.fillStyle = chrome(ctx, sx - sr, sy - sr, sx + sr, sy + sr); ctx.fill();
        ctx.save(); ctx.translate(sx, sy); ctx.rotate(st.aS * 3); ctx.fillStyle = "#17181a"; for (let i = 0; i < 4; i++) { ctx.rotate(Math.PI / 2); ctx.fillRect(sr * 0.45, -1, sr * 0.4, 2); } ctx.restore();
      }
      // gate light leaking past the film, and the hot front element
      ctx.globalCompositeOperation = "lighter";
      const gt = ctx.createRadialGradient(134, -25, 1, 134, -25, 26); gt.addColorStop(0, `rgba(255,236,204,${0.55 * fl})`); gt.addColorStop(1, "rgba(255,236,204,0)"); ctx.fillStyle = gt; ctx.fillRect(100, -60, 60, 70);
      const hot = ctx.createRadialGradient(LENS.x, LENS.y, 2, LENS.x, LENS.y, 78); hot.addColorStop(0, `rgba(255,250,240,${0.95 * fl})`); hot.addColorStop(0.16, `rgba(${mix(tone, 0.25)},${0.6 * fl})`); hot.addColorStop(1, `rgba(${mix(tone, 0.4)},0)`);
      ctx.fillStyle = hot; ctx.beginPath(); ctx.arc(LENS.x, LENS.y, 78, 0, Math.PI * 2); ctx.fill();
      ctx.fillStyle = `rgba(255,252,244,${0.95 * fl})`; ctx.beginPath(); ctx.ellipse(LENS.x, LENS.y, 5, 22, 0, 0, Math.PI * 2); ctx.fill();
      ctx.globalCompositeOperation = "source-over";
      ctx.restore();
      { const j = live ? Math.floor(now / 50) : 0; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.drawImage(grainAll, -((j * 37) % 192), -((j * 91) % 192)); }
    };

    const tick = (now: number) => {
      const dt = Math.min(0.033, (now - last) / 1000); last = now;
      if (!c0.paused && now > c0.nextAuto) { c0.target += 1; c0.nextAuto = now + HOLD_MS; setActive(((c0.target % N) + N) % N); }
      if (st.cut >= 1 && st.shown !== c0.target) { st.from = st.shown; st.shown += Math.sign(c0.target - st.shown); st.cut = 0; }
      if (st.cut < 1) st.cut = Math.min(1, st.cut + dt / 0.26);
      const want = (((st.shown % N) + N) % N) / (N - 1);
      st.p += (want - st.p) * Math.min(1, dt * 2.2);
      const rS = HUB + 6 + (0.86 - 0.62 * st.p) * (REEL - 16 - HUB), rT = HUB + 6 + (0.16 + 0.62 * st.p) * (REEL - 16 - HUB);
      st.aS += (SPEED / rS) * dt; st.aT += (SPEED / rT) * dt;
      if (now > st.scratchT) { st.scratchX = 0.08 + Math.random() * 0.84; st.scratchT = now + 500 + Math.random() * 2400; }
      for (const m of motes) { m.u += m.du * dt; m.v += m.dv * dt; if (m.u < 0.05 || m.u > 1) m.du = -m.du; if (m.v < -1 || m.v > 1) m.dv = -m.dv; }
      draw(now);
      if (visible && !document.hidden) raf = requestAnimationFrame(tick);
    };

    const size = () => {
      const cw = wrap.clientWidth, narrow = cw < 700;
      L = layoutFor(narrow);
      const sc = Math.min(narrow ? 1 : 1.12, cw / L.W);
      W = cw; ox = (cw / sc - L.W) / 2;
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      k = dpr * sc;
      const h = Math.round(L.H * sc);
      wrap.style.height = `${h}px`;
      cv.width = Math.round(W * dpr); cv.height = Math.round(h * dpr);
      build(); draw(performance.now());
    };
    c0.redraw = () => {
      if (!reduce) return;
      st.shown = c0.target; st.cut = 1; st.p = (((st.shown % N) + N) % N) / (N - 1);
      draw(performance.now());
    };
    size();
    const ro = new ResizeObserver(size); ro.observe(wrap);
    const start = () => { cancelAnimationFrame(raf); if (!reduce && visible && !document.hidden) { last = performance.now(); raf = requestAnimationFrame(tick); } };
    const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; start(); });
    io.observe(wrap);
    document.addEventListener("visibilitychange", start);
    start();
    return () => { cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); document.removeEventListener("visibilitychange", start); c0.redraw = () => {}; imgs.forEach((im) => im.removeEventListener("load", loaded)); };
  }, [reduce]);

  const onKey = (e: ReactKeyboardEvent) => {
    if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); step(1); }
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); step(-1); }
  };
  const pause = (v: boolean) => () => { ctl.current.paused = v; if (!v) ctl.current.nextAuto = performance.now() + HOLD_MS; };
  const btn = "grid h-10 w-10 place-items-center rounded-full border border-white/15 bg-white/5 text-neutral-200 transition hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200";

  return (
    <section className="relative overflow-hidden bg-[#0c0b0a]" onMouseEnter={pause(true)} onMouseLeave={pause(false)} onFocus={pause(true)} onBlur={pause(false)}>
      <div
        ref={wrapRef}
        role="group"
        aria-roledescription="carousel"
        aria-label="Film projector showing a reel of stills. Use the left and right arrow keys to change the still."
        tabIndex={0}
        onKeyDown={onKey}
        className="relative w-full outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-amber-200/70"
        style={{ height: 700 }}
      >
        <canvas ref={cvRef} aria-hidden="true" className="absolute inset-0 h-full w-full" />
        <p aria-live="polite" className="absolute bottom-5 left-5 font-mono text-[11px] uppercase leading-relaxed tracking-[0.18em] text-neutral-300 sm:bottom-8 sm:left-8">
          <span className="tabular-nums text-white">{String(active + 1).padStart(2, "0")}</span>
          <span className="mx-2 text-neutral-500">/</span>
          <span className="tabular-nums">{String(N).padStart(2, "0")}</span>
          <span className="block text-neutral-300">{stills[active].title}</span>
        </p>
        <ul className="sr-only">
          {stills.map((s, i) => (
            <li key={s.src} aria-current={i === active ? "true" : undefined}>
              <img data-still="" src={s.src} alt={s.alt} width={1000} height={667} loading="eager" />
            </li>
          ))}
        </ul>
        <div className="absolute bottom-4 right-4 flex gap-2 sm:bottom-7 sm:right-8">
          <button type="button" onClick={() => step(-1)} aria-label="Wind back to the previous still" className={btn}>
            <svg aria-hidden="true" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4l-6 6 6 6" /></svg>
          </button>
          <button type="button" onClick={() => step(1)} aria-label="Run on to the next still" className={btn}>
            <svg aria-hidden="true" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M8 4l6 6-6 6" /></svg>
          </button>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/sixteen-millimetre-film-projector-gallery-with-turning-reels-a-dusty-beam-and-stills-that-flicker-onto-a-tripod-screen.json

When to use it

How to customize it

Accessibility

Questions

How is the photo drawn at an angle on a flat canvas?

The screen is a four cornered shape whose right edge is taller than its left. The photograph is cut into 72 upright slices and each slice is stretched to the height the screen has at that point. Across a few hundred pixels that reads as a surface turned away from you, with no 3D library.

Why do the two reels turn at different speeds?

Film leaves one reel and arrives on the other at the same speed, so a reel's turning rate is that speed divided by the radius of the film wound on it. As the gallery advances, the feed reel empties and speeds up while the take-up reel fills and slows down. After the last still both ease back to the start.

Do the photos need to allow cross origin use?

No. The canvas only draws them and never reads pixels back, so ordinary image addresses work. They are loaded with `new Image()` as soon as the component mounts, which means the first still is on screen without waiting for lazy loading.

Can I show video instead of stills?

Yes. Create a muted, looping `video` element in the effect and pass it to `drawImage` in the `frame` function in place of the image, using `videoWidth` and `videoHeight` for the crop. The slices, flicker and beam work the same way.

What does it cost per frame?

The room, table and projector body are painted once into two off-screen canvases. A frame then copies those, draws the 72 slices, two reels, about seventy dust motes and a few gradients. Drawing stops when the section leaves the viewport or the tab is hidden.

Related: Slide Projector Photo Carousel with a Ribbed Tray That Clicks Round and Drops Each Frame into the Beam, Film Clapperboard Call to Action with Sticks That Snap Shut, Chalked Scene Details and a Running Timecode Countdown.

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

More in Galleries

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