Halcyon Film Lab ↖ gallery

Making-of note · frame 15

A website you wind.

The site treats Halcyon’s story as a length of developed 35mm: five large frames pass through a physical aperture, edged by sprockets and blemished by light. The strip is not a decorative background; it is the document’s navigation.

Concept

The lab’s pace is the interface.

Halcyon is intentionally specific: a small Oslo lab, a named technician, a real kind of after-hours drop box, and the strange little notes that come with a roll of film. The writing avoids the promise of “perfect” scans because a conscientious lab should care more about preserving a photograph’s character.

The film base carries the page. It has dark emulsion, orange edge light, physical perforations, contact-sheet frames, and a little instability. Lora keeps the long-form copy warm; Courier Prime carries the practical markings a film lab would actually stamp onto a sleeve.

Material

Nothing imported as a photograph.

The bottle rack and contact sheet are CSS-built abstractions. A small canvas lays a changing layer of warm monochrome grain over the page; fixed radial gradients supply the red/orange light leaks and each frame has its own halation.

The grain loop is low-frequency, has its pixel density capped at two, and pauses when the page is hidden. Reduced-motion users see the layout without any animated texture.

Signature technique

The strip is a real horizontal scroll surface.

The frames are actual sections in a horizontally overflowing scroller. A wheel over the aperture becomes horizontal travel, touchpads retain their native sideways travel, and arrow keys plus the controls move between frames. Once input pauses, the closest section is wound into place with a short overshoot, then a settle — closer to a spring-loaded film advance than a carousel.

const cancelWind = () => {
  if (settling) cancelAnimationFrame(settling);
  settling = 0;
  winding = false;
};

const windTo = (index, announce = true) => {
  const nextIndex = clamp(index, 0, frames.length - 1);
  const start = scroller.scrollLeft;
  const target = targetFor(nextIndex);
  cancelWind();
  clearTimeout(settleTimer);
  if (document.hidden || reduceMotion.matches || Math.abs(target - start) < 2) {
    programmaticScrollUntil = performance.now() + 80;
    scroller.scrollLeft = target;
    setActive(nextIndex, announce);
    return;
  }
  winding = true;
  const direction = Math.sign(target - start) || 1;
  const overshoot = clamp(Math.abs(target - start) * .05, 10, 26) * direction;
  const duration = 410;
  const started = performance.now();
  const wind = (now) => {
    const elapsed = clamp((now - started) / duration, 0, 1);
    let position;
    if (elapsed < .76) {
      position = start + ((target + overshoot) - start) * easeOutCubic(elapsed / .76);
    } else {
      position = target + overshoot * (1 - easeOutCubic((elapsed - .76) / .24));
    }
    programmaticScrollUntil = performance.now() + 80;
    scroller.scrollLeft = clamp(position, 0, scroller.scrollWidth - scroller.clientWidth);
    if (elapsed < 1) settling = requestAnimationFrame(wind);
    else {
      settling = 0;
      winding = false;
      programmaticScrollUntil = performance.now() + 80;
      scroller.scrollLeft = target;
      setActive(nextIndex, announce);
    }
  };
  settling = requestAnimationFrame(wind);
};

Palette

Developer, metal, safelight.

Film base orange
#E8823A
Silver
#C8C6BE
Darkroom red
#8B1E1E
Emulsion black
#080706

Type

Label, then letter.

Body · Lora, Georgia, serif
A clean file can still remember the grain.
Labels · Courier Prime, Courier New, monospace
FRAME 04 / 05 · SCANNER LIGHT · RGB

Build decisions

What was given more attention.

  1. The mechanical advance came first. It deliberately animates scrollLeft on the actual film strip rather than pretending to move an image of a strip.
  2. Every frame has its own layout — hero/inventory, process ledger, bottle bench, contact sheet, and counter notebook — so scrolling feels like exposing different negatives, not paging through identical cards.
  3. Mobile keeps the strip horizontal but gives each frame a safe width and independently scrollable content area. The guide’s code block alone may scroll sideways; the document itself does not.

Iteration log

Pass 1 — correctness

  • Stopped the scroll handler from restarting the mechanical overshoot while the strip itself is moving; pointer and wheel input now cancel an in-flight wind cleanly.
  • Paused the grain timer and the winding work while the document is hidden, capped canvas DPR at 2, and respond when reduced-motion is switched on after load.
  • Made the film controls and required gallery/making-of links at least 44px tall, enlarged the smallest practical labels, gave CSS illustrations useful image roles, kept focus outlines visible, and put each contact-sheet frame number on an opaque dark backing for reliable contrast.
  • Known scope limitation: the required / gallery links remain in place, but this supplied lab directory has no local root gallery page to validate; changing that route would require work outside this lab.

Pass 2 — design depth

  • Weak moment: the frames still shared one polite inset-card rhythm. The developer bench now breaks through the lower and left edges as a red safelight work surface; the contact sheet is deliberately tilted and runs off the frame’s lower edge; the visit card becomes a slightly rotated counter slip. Mobile restores each composition to a safe, contained stack.
  • Weak moment: display type was handsome but too even. A defined spacing scale now sets the strip’s cadence, and frame titles are larger, tighter, and balanced to avoid accidental one-word tails. The process frame also narrows its copy column so the ledger reads as its own dominant object.
  • Weak moment: the physical-film effects were quieter than the concept. Darkroom red now has an intentional home in the bench and counter-note frames, while an orange/red gate-flash appears only during the existing mechanical wind. Its bespoke mechanical curve and reduced-motion removal keep the feedback tied to navigation rather than decoration.

Pass 3 — complexify

  • Missing moment: the visitor could wind the story but not examine a negative. A second, below-the-fold light table now lets visitors choose a proof and move a small 8× loupe across it. The lens follows the table coordinates, carries the selected proof’s colour and grain, and stays out of the way until there is an intentional pointer action.
  • Edge state: the fourth proof is unexposed leader. Selecting it does not imply a broken thumbnail. The loupe deliberately rests and the caption explains that the orange strip is breathing room between exposures. The same selection works by click, focus, and arrow keys, with a polite announcement for assistive technology.
  • Finish: texture now fades in only after its first canvas paint instead of arriving as a hard overlay. Fonts use an optional loading policy to avoid a late reflow, the page and guide gained complete social metadata, and the responsive/reduced-motion review kept the page body from acquiring a horizontal scroll.