Flight Log
How This Site Was Built
This page documents the actual implementation of the AVIARY direction for Nomadic Owls — one of ten visual directions built on identical real content. It's written for someone who wants to reproduce the approach, not for marketing.
Concept
AVIARY treats the agency as a nocturnal aviator's cockpit: flying an owl-shaped constellation through a living, code-generated night sky. The brand is Nomadic Owls, so the natural visual metaphor was a real, working night sky — not a photo of one — rendered in WebGL, with the owl reappearing as a literal constellation rather than a logo redraw. The whole page is a descent: the hero reads "ALT 38,000 FT" and the number ticks down as you scroll, bottoming out near the contact section like a plane on final approach.
The Three.js scene
The scene lives in src/scripts/starfield.js and mounts into a fixed, full-viewport #starfield-rootdiv declared once in src/layouts/Base.astro, behind every page's content (z-index: 0, content sits atz-index: 3).
- Starfield: a single
THREE.BufferGeometrywith 3,600 points (a deliberate ceiling for perf on mid laptops), positions randomized in a box roughly 140×90×180 units deep, colored per-vertex from a weighted palette (62% starlight white, 18% teal, 16% indigo, 4% ember) so the sky reads as varied without any per-point shader work. - Ember twinkle: the ~4% of points that got the rare ember color keep their buffer index in a small array at setup time. Each frame, just those entries get their RGB rewritten to
emberColor * (0.55 + 0.45 * sin(t * 0.8 + phase))with a per-star random phase, thencolor.needsUpdate = trueis set once. No shader, no extra draw call — a handful of typed-array writes on top of the geometry that's already rendering, so the rest of the field (the other 96% of points) stays completely static per-frame work. - Star sprite: rather than shipping a PNG sprite, the soft circular point texture is painted at runtime onto a 64×64
<canvas>with a radial gradient and turned into aTHREE.CanvasTexture. Zero extra network requests, zero extra image budget spent. - Owl constellation: a hand-plotted, 13-point closed polyline (ear tufts, shoulders, wingtips, hips, tail) in a local -8..8 unit space, drawn with
THREE.Line, plus a 3-segment "face" (eye-line and two lines down to a beak point) drawn withTHREE.LineSegmentsin the ember accent color. The vertices double as brighterTHREE.Pointsnodes. It's intentionally angular — real constellations are drawn as straight segments, so a stylized, geometric owl reads more "real" than a smoothed silhouette would. - Parallax: pointer position is normalized to -1..1 and lerped (5% per frame) into the rotation of a parent rig group holding both the starfield and the owl, so the whole sky drifts gently toward the cursor like a canopy view, never snapping.
GSAP ScrollTrigger choreography
Two scrubbed timelines drive the "flight" feeling, both registered through gsap.registerPlugin(ScrollTrigger):
- A master timeline pinned to
document.body(start: "top top",end: "bottom bottom",scrub: 1.1) drives the camera fromz = 40down toz = 14across the entire page, plus a small rig roll — the sensation of descending toward the horizon as you read. - A second timeline scoped to just the
#worksection (start: "top 75%",end: "bottom 40%") fades the owl constellation's line opacity, node size and node opacity up from a dim resting state to full brightness. The owl is barely visible during the hero and pillars, and becomes the clearest object on screen exactly when the "Selected Work" constellation section is in view — tying the 3D background to the page's own structural gimmick instead of running in parallel to it.
Both timelines write into small plain-object proxies (flight, owlFade) that therequestAnimationFrame loop reads each frame, rather than letting GSAP tween Three.js objects directly. That kept the render loop as the single place doing WebGL writes.
Reduced motion and no-WebGL fallback
initStarfield() checkswindow.matchMedia("(prefers-reduced-motion: reduce)")and a WebGL context probe before doing anything else. If either fails, it paints a single static field of dots onto a plain 2D canvas once and never touches requestAnimationFrameagain — no WebGL context is created at all in that path. Section reveal animations (src/scripts/main.js) follow the same rule: every element is fully visible by default in CSS, and JS only adds a fade/slide-in on top of that default when motion is allowed, so the site never depends on JavaScript running to be readable.
Typography and color
Display type is Space Grotesk, body type is Inter, both self-hosted via @fontsource/space-grotesk and@fontsource/inter (specific weight files imported in Base.astro, e.g. 400.css,600.css) rather than loaded from a Google Fonts CDN — no third-party request, no render-blocking round trip, and only the weights actually used ship in the build.
The palette is five values as CSS custom properties insrc/styles/global.css: a near-black void (#05070d), two darker indigo/teal steps (#0d1b2a, #1b3a4b) used for card and panel backgrounds, one warm ember accent (#ffb703) reserved for CTAs, focus rings and HUD micro-labels, and a starlight white (#f3f5fa) for text, used at three opacities instead of introducing more named colors.
Generated imagery
One image was generated with Higgsfield'sgpt_image_2 model, at 16:9, with the prompt:
"a vast dark nebula in deep indigo and teal with scattered pinpoint stars, wisps of violet cosmic dust, cinematic wide shot, no text, ultra high detail"
It was downsized and re-encoded with sharp into two derivatives (see scripts/optimize-images.mjs): a 2,400px-wide WebP at 68% quality (public/images/nebula-bg.webp, ~215 KB) used as a low-opacity (16%), screen-blended texture layer behind the Manifesto section only, and a 1200×630 JPEG crop used as the og:image. It deliberately does not appear in the hero — the brief was clear that the real Three.js scene should be the star of the show, and a static image competing with it in the first viewport would undercut that.
Honest tradeoffs
- No real bloom pass. A physically-lit glow around bright points would normally come from an
EffectComposer+UnrealBloomPasspost-processing chain. That adds a second full-screen render pass and meaningfully more GPU cost for a background layer that's supposed to stay out of the way of scrolling and reading. Instead, the glow is faked with additive blending on a soft canvas-drawn sprite. It looks convincing at the star sizes used here but won't hold up if the point size or count were pushed much higher. - The reduced-motion fallback is a hard cut, not a graceful degrade. Users who prefer reduced motion get a single static paint and lose the flight/scroll narrative entirely, rather than a toned-down version of it (e.g. a slow, non-parallax drift). That was a deliberate choice for simplicity and to avoid accidentally shipping "reduced but still moving" motion that doesn't actually respect the preference, but it does mean two genuinely different experiences of the site exist side by side.