Bonus Reel

How This Site Was Built

The write-up nobody asked for: concept, the ScrollTrigger pinning technique, the type system, and the two tradeoffs I'd defend in a review.

← Back to the reel

Concept & inspiration

The brief for this direction was "type is the only imagery" — treat the site like a film's opening title sequence, where scroll acts as the playhead and each section is a scene that cuts hard to the next. No hero photography, no product shots, no team headshots. The entire visual language had to come from scale, color, and motion applied to type.

The reference point wasn't really web design — it was actual film title sequences (the Saul Bass school: giant condensed type, one flat color per beat, a running timecode readout) and the literal aesthetic of a "credits roll": scene numbers, act labels, a cast list ("Starring"), and a closing "FIN." card. Nomadic Owls' real content (three pillars, six disciplines, three shipped projects, a manifesto, four beliefs, one contact CTA) maps cleanly onto that structure — it reads like a ten-scene reel without inventing anything.

The ScrollTrigger pinning technique

Each scene is a <section class="scene"> with a natural height of 100vh. For every scene, one GSAP timeline is created with its own ScrollTrigger:

gsap.timeline({
  scrollTrigger: {
    trigger: scene,
    start: "top top",
    end: "+=" + distance,   // e.g. "150%" of the viewport
    scrub: 0.6,
    pin: true,
    pinSpacing: true,
    anticipatePin: 1,
  },
})
  .from(eyebrow, { opacity: 0, y: 16 })
  .from(title, { opacity: 0, scale: 0.86, transformOrigin: "0% 50%" }, 0.15)
  .from(body, { opacity: 0, y: 24 }, 0.7);

pin: true fixes the section in the viewport once its top hits the top of the screen, and end: "+=150%" tells ScrollTrigger to hold that pin for 1.5 viewport heights of additional scroll before releasing it. Internally, ScrollTrigger inserts a spacer element equal to that distance so the document's total scroll height accounts for the time the section spent pinned — that's the "explicit, intentional scroll-distance spacer" the pinned pattern needs. Get the distance value wrong and you either get a scene that snaps past before anyone can read it, or one that lingers awkwardly with nothing left to animate.

scrub: 0.6 ties the timeline's playhead directly to scroll position (with a small 0.6s catch-up lag so it doesn't feel robotic) instead of playing once on trigger — that's what makes scroll feel like the actual film playhead. All the scrubbed properties are opacity and transform (scale, y) only, never width/height/top/left, so each scrub tick is a compositor-only paint, not a layout recalculation — this matters a lot across ten pinned scenes firing on every scroll frame.

Three gotchas I actually hit building this, in the order I hit them:

  1. Webfont swap breaks pin math. Bebas Neue loads asynchronously (self-hosted via @fontsource, but still a separate network request). ScrollTrigger measures each scene's start/end pixel positions on init, using whatever font is rendering at that moment — usually the fallback. When Bebas Neue swaps in a few hundred milliseconds later, line heights shift and every pin position downstream of the first is now off by that delta. Fix: document.fonts.ready.then(() => ScrollTrigger.refresh()) once the real fonts have actually painted.
  2. Duplicate triggers on resize. Naively calling the pin setup function again on window resize (for responsive re-init) creates a second full set of ScrollTriggers stacked on top of the first, doubling the scroll distance. The fix is gsap.context() per scene plus ScrollTrigger.matchMedia() for the responsive breakpoint itself — matchMedia reverts everything created inside a query's callback automatically the moment that query stops matching, so crossing the 800px breakpoint tears down cleanly instead of piling up.
  3. Mobile viewport height is not stable. iOS/Android show and hide the address bar mid-scroll, which changes window.innerHeight while a pin is active — Safari in particular will re-measure and glitch the pinned element. Rather than fight it, pinning is disabled below 800px entirely (see the tradeoffs section below).

The fallback, honestly

The whole enhancement is one <script type="module"> block wrapped in a top-level try/catch, and GSAP/ScrollTrigger are loaded with a dynamic import() rather than a static one — a static import that throws would fail the whole module before the catch could run. If that import rejects (bad deploy, offline, an overzealous ad-blocker that flags anything with "gsap" in the URL — it happens), or if anything inside setup throws, the catch block strips every enhancement class off <html> and does nothing else.

That "does nothing else" is the important part: the base CSS never sets opacity: 0 on scene content for the desktop pin path. GSAP sets that hidden state itself, at runtime, via .from(). So if GSAP never runs, nothing was ever hidden — the page is just a normal stack of full-color sections in document flow, readable top to bottom, no motion required to see any of it. The mobile and reduced-motion reveal styles do use a CSS opacity: 0 baseline (toggled by an IntersectionObserver), so those two paths are wrapped a little more defensively — the same outer catch strips their classes too, and the underlying content sits in normal flow the moment it does.

prefers-reduced-motion: reduce is checked before any of this runs. If set, pinning is skipped entirely — scenes render at their natural height in normal document flow with a simple, non-scroll-linked opacity fade as each one enters the viewport. No scale, no pin, no scroll-scrubbed transforms.

Typography & color

Two fonts, self-hosted via @fontsource: Bebas Neue for every display headline (huge, condensed, all-caps, poster-scale — it only ships one weight, which is fine, that's the point) and JetBrains Mono for everything else — scene labels, the timecode HUD, and, deliberately, the body paragraphs too. Using a monospace font for run-on prose is unconventional, but it reads as "technical readout" rather than "article," which is the right register for a credits-roll site; line-height is pushed to 1.65 and measure is capped at 58 characters to keep it comfortable at paragraph length.

Four background colors, never repeating back-to-back: ink black (#0a0a0c), an electric cobalt (#1128d6), a safety orange (#ff4d00), and an acid yellow (#e8ff3d). Each scene's type color is whichever of cream (#f4efe1) or ink contrasts more with its own background — verified against WCAG AA for body text size, not eyeballed:

  • ink bg / cream text — 17.2:1
  • cobalt bg / cream text — 7.95:1
  • orange bg / ink text — 5.95:1
  • yellow bg / ink text — 17.7:1

All four clear the 4.5:1 AA threshold for normal-size body text with real margin to spare.

Two tradeoffs I'd defend

1. The scroll is long on purpose, and that's a real cost. Ten pinned scenes at their configured distances add up to roughly 15 viewport heights of scroll on desktop — long, even for a scroll-driven site. That's the format: a title sequence has a runtime, and cutting it down to "efficient" scroll distances would undercut the entire pretense. The honest downside is that a visitor who wants to skim gets a materially slower path to the contact CTA than a normal one-page site would give them. Mitigation: every scene is a real anchor-able <section id>, so a scroll-averse visitor (or search engine) can still jump straight to #fin.

2. Pinning is desktop/tablet-only, by design. Below 800px, scenes never pin — they get the same content in a plain stacked layout with a fade-and-rise-in reveal instead of the full scrub-scale treatment. That's a deliberate simplification, not an oversight: mobile browser chrome resizing the viewport mid-pin is a well-documented, hard-to-fully-fix class of bug, and a broken pin (content stuck, jumping, or re-triggering) is worse than no pin. The tradeoff is that roughly a third of visitors (mobile traffic share varies, but it's rarely small) never see the site's main visual trick at all — only a toned-down version of it.