Field notes / How it's made
Solstice is a landscape with no images, no textures and no 3D models — one full-screen GLSL fragment shader whose only creative input is your local clock. Dawn, noon, dusk and night are not four scenes; they are one continuous function of time.
The entire 24-hour palette of the site, sampled from the same math the shader runs — this strip is also the scrubber track on the main page.
Most websites look the same at 3 a.m. as they do at 3 p.m. Solstice treats the visitor's clock as an art director: the page reads new Date() and derives every color, light source and particle from it. Visit at dawn and you get first light; visit at midnight and you get stars, a moon and fireflies.
Because trust needs proof, the page includes a time scrubber — a 24-hour timeline painted with the day's actual palette. Drag it and you time-travel; release the Return to now button and the sky settles back to your real hour.
The whole scene is a single fragment shader on one full-screen triangle (raw WebGL 1, no libraries). The DOM sits on top for typography and the scrubber. Three uniforms drive everything:
// the entire interface between JS and the painting
uniform vec2 uRes; // canvas resolution
uniform float uTime; // seconds, for idle motion (clouds, water, stars)
uniform float uDay; // hours 0..24 — the star of the show
uniform vec2 uPar; // mouse parallax, eased in JS
Everything else — sun, moon, sky gradient, five mountain ridges, a lake with reflections, stars, clouds, fireflies, shooting stars — is computed per-pixel from those numbers.
Solstice uses a stylized solar day: sunrise pinned at 06:00, sunset at 18:00, so the model is legible anywhere on Earth. One sine turns clock time into solar elevation, and three derived scalars key every visual decision:
float se = sin((uDay - 6.0) / 24.0 * TAU); // elevation: -1 midnight .. +1 noon
float dayI = smoothstep(-0.12, 0.28, se); // "how much daytime"
float tw = exp(-abs(se) * 4.5); // twilight: peaks when the sun crosses the horizon
float nightI = 1.0 - smoothstep(-0.18, 0.02, se);
tw is the secret ingredient: it spikes exactly at sunrise and sunset, and it is what injects amber and rose into the horizon, warms the clouds, rims the ridgelines and re-tints the UI accent color — all from one exponential.
The moon simply runs the same sine phase-shifted by twelve hours: melev = sin((uDay - 18.0) / 24.0 * TAU), so it culminates at midnight. Its crescent is two offset discs subtracted from each other.
The sky is a vertical gradient between a horizon color and a zenith color. Both are first blended between night and day endpoints by dayI, then twilight warmth is layered on top — strongest near the sun's azimuth, so sunsets glow on the correct side of the frame:
vec3 zen = mix(vec3(0.012,0.020,0.045), vec3(0.16,0.42,0.75), dayI);
vec3 hor = mix(vec3(0.045,0.070,0.130), vec3(0.62,0.78,0.90), dayI);
float warmSpread = exp(-abs(p.x - sunP.x) * 2.2); // hug the sun's side
vec3 twc = mix(vec3(1.0,0.42,0.16), vec3(0.85,0.28,0.40), p.y*1.8); // amber → rose
hor = mix(hor, twc, tw * (0.40 + 0.52*warmSpread));
zen = mix(zen, vec3(0.28,0.16,0.38), tw * 0.40); // violet zenith at dusk
Clouds are one fBm field whose color swings from near-black (night) to white (day) to amber (twilight) — the same cloud, relit. During twilight a second fBm, stretched horizontally (p.y*16), is thresholded into stratus glow bands that hug the horizon on the sun's side.
Early builds proved a hard lesson: at 3 a.m. "physically honest" means "nearly black screen". The fix was a set of deliberately theatrical night layers, all gated by nightI:
exp(-d*26) glow that reads as a bright star.exp(-dist²·26) from a tilted axis) filled with bright fBm dust and cut by a second fBm of dark lanes, with a warm tint in the dense cores.exp(-((d-0.085)*70)²).exp(-(y-WY)*9)), the same trick real night-sky photos show.Five silhouette layers use ridged fBm — fBm built from folded noise, abs(n*2-1), which produces sharp crests instead of soft hills. Each layer gets a higher frequency, a lower baseline and less atmospheric fog, so depth reads instantly:
float ridged(vec2 p){
float v = 0.0; float a = 0.55;
for(int i = 0; i < 4; i++){
v += a * abs(noise(p)*2.0 - 1.0); // fold → sharp ridges
p = p*2.15 + vec2(5.2, 9.7); a *= 0.5;
}
return 1.0 - v;
}
// per layer i: nearer ⇒ lower, darker, less fog, more parallax
float base = 0.47 - fi*0.062;
float h = base + (ridged(vec2(px, fi*3.1)) - 0.52) * amp * 2.0;
vec3 lc = mix(darkLayer, horizonColor, fog * 0.85); // aerial perspective
col += sunCol * rim * tw * 0.16 * warmSpread; // warm rim light at golden hour
The fog color is literally the sky's horizon color, so aerial perspective stays truthful at every hour: blue-grey at noon, rose at dusk, near-black at night.
The bottom 17.5% of the frame is a lake. The trick: the whole upper scene lives in a function scene(p), so the reflection is just a second call with a mirrored, wobbled coordinate:
float wob = noise(vec2(p.x*80.0, (WY-p.y)*55.0 - uTime*0.45))*2.0 - 1.0;
vec2 rp = vec2(p.x + wob*0.010*(0.25+depth), 2.0*WY - p.y*0.92);
vec3 refl = scene(rp, aspect); // the sky, mountains, sun — mirrored
col = mix(refl * mix(0.80,0.38,depth), deepWater, 0.30 + depth*0.45);
On top of that, a light path: high-frequency noise thresholded into glitter, masked by exp(-|p.x - sunX|) so sparkles concentrate under the sun by day and under the moon by night.
A jittered cell grid over the meadow band; each cell with a lucky hash gets one drifting, pulsing amber dot, gated by nightI.
Time is diced into 11-second windows; a hash decides whether a window fires. The streak is a point-to-segment distance with a fading tail.
One octave of value noise darkening the crescent disc — cheap, but reads as texture at 25 px.
Per-pixel hash noise at ±1.6% keeps the long gradients from banding and gives the frame a photographic finish.
The scrubber is plain DOM — a role="slider" with pointer capture and arrow-key support. Its track gradient is generated at boot by running the same palette math, ported to JS, at 48 sample times. One source of truth, two renderers:
function horizonRGB(day){
const se = Math.sin((day-6)/24 * TAU);
const dayI = sstep(-0.12, 0.28, se), tw = Math.exp(-Math.abs(se)*4.5);
let hor = mix3([0.045,0.070,0.130], [0.62,0.78,0.90], dayI);
return mix3(hor, [1.0,0.42,0.16], tw*0.72); // same twilight injection
}
track.style.background = gradientFrom(horizonRGB, 48);
Scrubbing never snaps: the displayed hour eases toward the target with a wrap-aware lerp — diff = ((target - shown + 36) % 24) - 12 — so dragging from 23:00 to 01:00 crosses midnight instead of rewinding the whole day. The UI accent color, the pulsing live-dot and the phase headline (First Light, Golden Hour, Dusk…) all re-derive from the same clock. Premium touches: the hover bubble names the phase alongside the time, sunrise/sunset markers sit at 06 and 18, and in time-travel mode a thin "now" tick stays on the track so you can always find your way home.
Version two grew the single screen into a full site — nav, four content sections and a footer scroll under the fixed canvas, and every piece of UI chrome is derived from the same solar math. That's the rule that keeps it feeling like one object:
The Dawn/Noon/Golden-Hour/Night cards aren't screenshots — each preview is a CSS gradient built at boot from zenithRGB(h) and horizonRGB(h), with a positioned sun/moon orb and a shared SVG ridge silhouette. Clicking one calls travelTo(h): smooth-scroll back to the sky while the wrap-aware lerp sweeps uDay through every hour in between — the transition is the animation.
The engine section plots se = sin((uDay−6)/24·τ) as an SVG path with sun and moon dots riding it, plus three meters for dayI, tw, nightI updated every frame the panel is on screen. Watching tw spike as you drag through 18:00 teaches the whole system in one gesture.
A canvas paints all 1,440 minutes of horizon color into one bar; pointer-move shows an hour+phase tooltip and a click time-travels there. One function, three renderers: shader, scrubber track, strip.
GSAP ScrollTrigger staggers each section's kicker, headline and cards; the hero copy drifts up and fades on a scrubbed tween as the page arrives. Lesson learned: scrub tweens must target a wrapper the CSS entrance choreography never touches, or GSAP captures opacity 0 as its start value and the hero never appears.
Two IntersectionObservers act as power gates: the WebGL draw is skipped entirely while the sky is scrolled out of view, and the instrument panel only writes to the DOM while visible.
webglcontextrestored; if WebGL is missing entirely, a CSS dusk gradient takes over.[-1,-1, 3,-1, -1,3] — no quad needed).h + m/60 + s/3600, feed it as uDay.se, dayI, tw, nightI as in §03 — key every color off them, never off raw time.pow(col, 0.92) lift.No AI-generated assets were used — every pixel is procedural, so the whole site ships in two small files. That constraint is the concept: when the medium is math, the clock can be the artist.