Alpenglow Expeditions is a fictional mountaineering outfitter built as a design showcase. Its centrepiece is a single full-viewport fragment shader — sun, scattering, clouds, spindrift, stars, aurora, four ridgelines — interpolated by one number: scroll progress. This page explains how, with the real code. It also runs the system on itself: drag the scrubber in section 03 and this page changes weather.
The brief: a six-day traverse told as one continuous climb. Scroll is altitude — an instrument HUD ticks from 1,200 m at basecamp to 4,808 m on the summit while the sky runs dawn → alpenglow → storm → dusk → summit night. The mountains and sky are 100% procedural: no photographs, no video, just mathematics lit by a palette of four claimed colours (pre-dawn #16233A, snow #F2F5F9, alpenglow #FF7E6B, pine #2C4A43).
Two voices carry the copy: Sentient, a serif with real warmth, for the romance — and Instrument Sans for the logistics: gear tags, altitudes, departure tables. Mountaineering is exactly this pairing; poetry administered through checklists.
The first four chips are the locked brand claim. The second row is live — it shows the CSS custom properties the atmosphere is writing right now, on this page.
Sentient carries the romance,
and Instrument Sans carries the logistics: 2 × 60 m half ropes, −18° comfort rating, 4 L of melted water per climber per day, one guide to three climbers.
Instrument Sans · 11px · 0.18em tracking — the voice of every label on the mountain
A ScrollTrigger spanning the whole journey emits one value, p ∈ [0,1].
A pure function atmos(p) interpolates eleven hand-set keyframes into a
full atmospheric state: sun elevation, warmth, storm, night, aurora, plus the three
palette anchors. That state goes two directions at once — into the
shader uniforms, and into CSS custom properties. The GPU and the DOM read the same
palette, so panels, rules, buttons and text are literally lit by the sky.
atmosphere.js — keyframes to state (excerpt)
const KEYS = [
{ p: 0.00, sunEl: -0.07, warmth: 0.45, storm: 0.10, night: 0.32,
zenith: [16,27,48], horizon: [58,83,120], glow: [232,146,107] }, // dawn
{ p: 0.20, sunEl: 0.035, warmth: 1.00, storm: 0.10, night: 0.00,
zenith: [34,67,107], horizon: [255,142,112], glow: [255,178,126] }, // alpenglow
{ p: 0.56, sunEl: 0.42, warmth: 0.02, storm: 1.00, night: 0.00,
zenith: [30,40,51], horizon: [70,82,93], glow: [108,120,131] }, // storm peak
{ p: 0.87, sunEl: -0.45, warmth: 0.00, storm: 0.04, night: 1.00,
zenith: [6,10,22], horizon: [22,35,58], glow: [143,227,198] }, // summit night
// ...11 keys total
]
const smooth = t => t * t * (3 - 2 * t) // smoothstep between neighbours
The bridge — same state, two consumers
function setP(p) {
state = atmos(p) // one pure function
sky.set(state) // → GPU: uZenith, uHorizon, uGlow, uStorm, uNight…
applyAtmos(state) // → DOM: --sky-zenith, --panel, --rule, --glow-text
}
// applyAtmos, the DOM half. Panels are darkened zenith; accents are the
// glow colour lifted until it reads as text on them:
st.setProperty('--panel', rgb(lerp3(s.zenith, [8,13,24], 0.55), 0.58))
st.setProperty('--glow-text', rgb(liftForText(s.glow)))
sky.frag.glsl — the scattering ramp (excerpt)
// horizon hue climbs higher into the sky when the sun is low
float sy = clamp((uv.y - HOR) / (1.0 - HOR), 0.0, 1.0);
float ramp = mix(0.52, 0.85, clamp(uSunEl * 1.6 + 0.5, 0.0, 1.0));
vec3 col = mix(uHorizon, uZenith, pow(sy, ramp));
// warm band hugging the horizon + mie-ish forward scatter around the sun
col += uGlow * exp(-abs(uv.y - HOR) * 6.5) * uWarmth * 0.38;
col += uGlow * sunVis * (disc * 1.1 + mie * 0.55 + bloom * 0.30);
Why sample the model on the CPU instead of gl.readPixels? Reading the
framebuffer back every frame stalls the pipeline; recomputing the palette from the
same keyframes costs nanoseconds and guarantees the DOM matches the GPU even before
the first frame renders. The mountains sink as you climb — four ridged-noise
silhouettes whose baselines drop with an uAscent uniform derived from
the altitude track — and under prefers-reduced-motion the scrub is
replaced by six static per-chapter states set by an IntersectionObserver.
The sky, mountains, storm, stars and aurora ship as ~200 lines of GLSL — zero image
weight. The only generated assets are the two guide portraits (Higgsfield
soul_2, 1 credit each) and the OG image, which is a real render of the
site's own dawn, captured headlessly. Masters live outside the build; the site ships
optimised WebP at 800/1600px.
“Editorial portrait of a female alpine mountain guide, early 40s, weathered wind-burned face… faded coral-red hardshell… golden hour on a glacier ridge, warm alpenglow rim light, cool pre-dawn blue shadows, 85mm, documentary expedition photography.” The wardrobe was art-directed to the site palette: coral shell, pre-dawn shadows.
Same lighting recipe, pine-green shell, “calm weather-reading gaze toward the horizon.” Shooting both guides in one consistent golden-hour setup is what makes them read as one expedition rather than two stock photos.
The mobile page was 8,570px tall, which crashes software-GL full-page captures —
slimmed every chapter below 901px. Killed a subtle double-smoothing bug:
CSS scroll-behavior: smooth on top of Lenis made programmatic scrolls
crawl (measured a jump-to-top still 5,362px away a second later). Made the
expedition day counter stepped — days don't interpolate. On phones the route now
draws itself once on entry instead of scrubbing after you've scrolled past it.
Re-timed the altitude track so the HUD holds at Camp II through the whole storm
(it had been quietly climbing mid-whiteout). The instruments now stand down as you
arrive, clearing the footer. Gear tags hang at hand-placed angles and straighten
when picked up; camps rise as they're reached. Then proved it all headlessly:
scrubber flips --sky-glow aurora-teal → alpenglow, reduced-motion
serves six static skies with everything visible.
Weakest screen was the phone route map — its annotations rendered at ~5px, so they now scale up inside the viewBox. The OG image became a real render of the site's own dawn rather than a generated poster. And the detail you only see if you stay a while: roughly every eleven seconds of summit night, one meteor.