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:
- 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. - 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 plusScrollTrigger.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. - Mobile viewport height is not stable. iOS/Android show and hide the address
bar mid-scroll, which changes
window.innerHeightwhile 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.