00Build guide

How Perigee was built.

A procedural shader planet, an orbit system whose labels are real DOM, and an incident log that types itself. No textures, no HDR maps, no images at all. Here's every technique, the code that does the work, and an honest log of what was broken on each pass.

Stack
Vanilla HTML, CSS, JS
WebGL
three.js 0.160.0
Type
Archivo · IBM Plex Mono
Assets
None. All procedural
Built by
Claude Opus 4.8
01

Concept

Perigee is a fictional infrastructure monitoring product. Perigee is the point in an orbit where you're closest to the thing you're circling, which is a decent name for a tool whose whole pitch is that it sees your stack before you do. The product promise ("your stack, from orbit") had to be literal on screen, so the hero is an actual planet with your services orbiting it.

The art direction is mission control, not sci-fi. That distinction drove every decision. Sci-fi is loud, neon and busy. Mission control is calm, monochrome and dense with numbers, because the people using it are tired and need to read it at 3am. So the palette is 90% deep space (#030612), 9% ice blue support, and amber reserved for exactly one job: something is wrong. Amber appears nowhere on this site except alerts. When a node pings in the hero, it's the only warm thing on the page, and your eye goes straight to it. That's the whole argument for accent discipline in one moment.

Type carries the same split. Archivo at width axis 118 for display gives the headline the wide, engineered, plaque-stamped feel of a NASA sign, and IBM Plex Mono handles anything that's a measurement. If a number could appear on an instrument, it's mono and tabular. If it's a sentence a human wrote, it's Archivo. That rule made the whole page easy: labels, readouts, timestamps, log entries and chart axes all fell out of it. Motion follows orbital mechanics, so nothing eases to a stop. The planet turns forever, the readouts tick, and the camera drifts on a slow sine even when nobody touches the pointer.

02

Technique breakdown

Fragment shader · terrain

Domain-warped continents

Straight fbm on a sphere gives you marble, not geography. Warping the sample position by another fbm first bends the noise into coastlines that fold back on themselves. The land mask is deliberately a narrow smoothstep well above the mean so land stays a minority of the surface, and a wide, soft band just below sea level paints the continental shelf, which is what actually sells the coast.

// warp the sample point, then read height at the warped position
vec3 q = vec3(fbm(p*1.25), fbm(p*1.25+vec3(5.2,1.3,2.8)), fbm(p*1.25+vec3(9.1,4.4,7.7)));
float base   = fbm(p*0.95 + 0.80*q);   // continent masses
float detail = fbm(p*3.40 + 0.90*q);   // coastline break-up
float h = base*0.84 + detail*0.16;
h = h*0.5 + 0.5;

float SEA  = 0.572;                     // above the mean -> ~24% land
float land = smoothstep(SEA-0.008, SEA+0.008, h);
// soft shelf hugging the coast, not a hard outline
ocean = mix(ocean, shelf, smoothstep(SEA-0.075, SEA-0.006, h));
Fragment shader · lighting

Terminator, fresnel atmosphere, city lights

Three things make a planet read as expensive: a terminator that curves, a limb that glows brighter on the sun side, and lights on the night half. The sun sits about 53° off the view axis, which is the sweet spot. Straight on and there's no terminator at all; perpendicular and it's a razor-straight line down the middle of the disc. City lights are gated on true night, not on the inverse of the day ramp, because the day ramp doesn't saturate until well past the terminator and leaking amber onto the lit half turns every landmass tan.

float ndl = dot(N, uSun);
float day = smoothstep(-0.16, 0.24, ndl);          // soft wrap, no hard edge

// tight sunrise band riding the terminator
float term = exp(-pow(ndl*11.0, 2.0)) * day;
col += vec3(1.0,0.66,0.28) * term * 0.06;

// gate lights on real night. (1.0-day) would bleed across the lit half.
float night = 1.0 - smoothstep(-0.30, -0.03, ndl);
float city  = smoothstep(0.50,0.70, speck) * density * land * night;
col += vec3(1.0,0.705,0.33) * city * 2.6;

// atmosphere shell, BackSide: N still points out, so the sun-side limb is bright
float fres = pow(1.0 - abs(dot(N, V)), 3.2);
float sun  = smoothstep(-0.35, 0.65, dot(N, uSun));
float a    = fres * (0.045 + 1.15*pow(sun, 1.5));
Aliasing

Why the city lights were a brown smear

The first build sampled city lights with fbm(p*44.0) at six octaves. Each octave doubles the frequency, so the last one landed near 1500 cycles across the sphere: features roughly a sixth of a pixel wide. Everything below the sample rate aliases into mush, and the "cities" rendered as a flat tan wash over every landmass. The fix isn't a bigger texture, it's fewer octaves. Two is enough when the base frequency is already high.

// before: last octave ~= 44 * 2.03^5 ~= 1518 cycles -> far below one pixel
float speck = fbm(p*44.0);            // 6 octaves -> aliased smear

// after: two octaves, top frequency ~54 -> ~4px features, resolves cleanly
float fbm2(vec3 p){ return (snoise(p) + 0.5*snoise(p*2.07)) / 1.5; }
float speck = fbm2(p*26.0)*0.5 + 0.5;
Compositing

Additive glow that doesn't punch a hole in the page

The canvas is transparent so the CSS field shows through it. But plain AdditiveBlending accumulates the alpha channel too, and the atmosphere's anti-sun limb writes alpha of about 0.045 while contributing almost no light. Browsers composite a WebGL canvas as canvas.rgb + page.rgb * (1 - canvas.a), so that dim alpha was subtracting the page background and ringing the planet with a dark halo. Adding RGB while leaving destination alpha untouched fixes it, and the glow now genuinely adds over the CSS gradient.

const ADD_KEEP_ALPHA = {
  transparent: true,
  blending: THREE.CustomBlending,
  blendEquation: THREE.AddEquation,
  blendSrc: THREE.SrcAlphaFactor,   // rgb: src*srcAlpha + dst
  blendDst: THREE.OneFactor,
  blendEquationAlpha: THREE.AddEquation,
  blendSrcAlpha: THREE.ZeroFactor,  // alpha: 0*src + 1*dst -> untouched
  blendDstAlpha: THREE.OneFactor,
  depthWrite: false
};
3D to DOM

Node labels are real text, projected

The region labels orbiting the planet aren't in the 3D scene. Each frame the node's world position is projected to screen space and written to a DOM element's transform, so the type stays real text at real subpixel quality with real letter-spacing. Occlusion is a ray-sphere test rather than a depth read: measure the perpendicular distance from the planet's centre to the camera-to-node ray, and if it lands inside the radius before the node, the label fades out.

node.getWorldPosition(wp);
_v.copy(wp).project(camera);
const x = (_v.x * 0.5 + 0.5) * W;
const y = (-_v.y * 0.5 + 0.5) * H;

// is the planet between the camera and this node?
_dir.copy(wp).sub(camera.position);
const dist = _dir.length(); _dir.divideScalar(dist);
_toC.copy(sys.position).sub(camera.position);
const tC   = _toC.dot(_dir);
const perp = _toC.clone().addScaledVector(_dir, -tC).length();
const occluded = tC > 0 && tC < dist && perp < 1.02;

el.style.transform = `translate3d(${x}px, ${y - 7}px, 0)`;
Choreography

One ping, wired to the whole page

The alert loop is a single state machine. When it fires, it doesn't just recolour a sprite: the node goes amber and pulses a ring, its arc to the next service turns amber and speeds up, the DOM label locks into a reticle, the HUD's node count flips to 5/6, and the status pill changes its text. One source of truth, five surfaces reacting. That's what makes the hero read as a system instead of a decoration.

function setAlert(on, node) {
  pill.classList.toggle('is-alert', on);
  nodeStat.classList.toggle('is-alert', on);
  pillTxt.textContent = on ? `${node.name} degraded` : 'All nodes nominal';
  nodeStat.innerHTML  = on ? `Nodes <b>5/6</b> degraded` : `Nodes <b>6/6</b> nominal`;
  node.el.classList.toggle('is-alert', on);
}
// arcs touching the degraded node go amber too
const hot = ARCS[k].includes(pingIdx) && pingOn;
o.mat.uniforms.uCol.value.copy(ICE).lerp(AMBER, hot ? 0.85 : 0);
03

Asset pipeline

There is no asset pipeline. This site ships zero image files. No textures, no HDR environment maps, no stock photography, no icon font. Every pixel that isn't type is computed in the browser.

The planet's surface, clouds, ice caps and city lights are all evaluated per fragment from a single 3D simplex noise function (Ashima's snoise, about 40 lines of GLSL) stacked into fbm. The atmosphere is a second sphere at 1.17× radius rendered back-face with a fresnel falloff. The star field is 2,600 points on a shell with per-star size and twinkle phase from a seeded PRNG, so it's identical on every load. The node glows are the only "textures" and they're canvas radial gradients drawn at runtime into a 128px CanvasTexture.

Everything in 2D follows the same rule. Icons are hand-drawn inline SVG. The topology graph, latency chart, ground track and the 24h ribbon are SVG paths generated from a seeded random walk at load, which is why the telemetry looks like telemetry rather than a smooth sine. The grain over the whole page is an inline feTurbulence data URI, because space photography never has clean blacks. The page is about 30KB gzipped over the wire, HTML and CSS together, and three.js is the only other thing to download. There are no images to wait for.

// the only bitmap on the site: a node glow, drawn at runtime
function glowTex() {
  const c = document.createElement('canvas'); c.width = c.height = 128;
  const x = c.getContext('2d');
  const g = x.createRadialGradient(64, 64, 0, 64, 64, 64);
  g.addColorStop(0,    'rgba(255,255,255,1)');
  g.addColorStop(0.16, 'rgba(255,255,255,0.72)');
  g.addColorStop(0.40, 'rgba(255,255,255,0.14)');
  g.addColorStop(1,    'rgba(255,255,255,0)');
  x.fillStyle = g; x.fillRect(0, 0, 128, 128);
  const t = new THREE.CanvasTexture(c);
  t.colorSpace = THREE.SRGBColorSpace; return t;
}
04

Recreate it

Paste this into Claude. It's structured Role, Task, Context, Format, Constraints, Examples, and it's written to produce something in the same register without cloning Perigee. Swap the product and the palette and the shape of the thing still holds.

Prompt Copied
ROLE
You are an art director and creative developer. You write hand-made vanilla
HTML/CSS/JS. No frameworks, no build step, no CSS libraries.

TASK
Build a single-page marketing site for a fictional developer product, plus a
/guide route documenting how you built it. The hero must contain one signature
WebGL moment that is the reason someone remembers the page.

CONTEXT
Product: [YOUR PRODUCT]. [One line of what it does].
Mood: [three adjectives, e.g. mission control, deep space, NASA-grade calm].
Palette: field [#hex], support [#hex], accent [#hex], text [#hex].
Type: [display face] for headlines, [mono face] for anything that is a
measurement. Load from Google Fonts with font-display: swap.
Money shot: [describe the one thing]. Pin three@0.160.0 from cdn.jsdelivr.net.

FORMAT
index.html + styles.css + guide/index.html. Inline the three.js scene as a
<script type="module"> so it also runs from file://. Screenshots go in shots/.

CONSTRAINTS
- The accent colour does exactly one job. Name that job and never use it for
  anything else. Roughly 90% field / 9% support / 1% accent.
- Anything that is a number a machine measured is mono and tabular-nums.
  Anything a human wrote is the display face. No exceptions.
- Procedural only: no texture files, no HDR, no stock images. Noise, canvas
  gradients and inline SVG. Seed every PRNG so loads are identical.
- Copy: contractions always. No em-dashes. Banned: seamless, leverage,
  unlock, revolutionary, game-changer, empower, elevate, cutting-edge.
  No testimonials, no invented customer quotes. Say the specific thing.
- Custom cubic-beziers named in :root. No default ease on hero motion.
- Every interactive element gets a designed hover AND focus-visible state.
- Wrap all decorative motion in prefers-reduced-motion: reduce.
- Must render from file:// and https://. Zero console errors.

EXAMPLES
- Good hero line: "Your stack, from orbit." Short, literal, and the WebGL
  behind it proves the claim. Bad: "Observability, reimagined."
- Good spec: "5 second checks, 13 month replay, 19 regions."
  Bad: "Powerful monitoring at scale."
- Good shader detail: a terminator that curves because the light is ~50deg
  off the view axis, plus city lights gated on true night. Bad: a lit sphere
  with a glow div behind it.

ITERATE
Screenshot at 1440 and 390, LOOK at the images, list what's weak, fix it.
Three passes: structure, then depth, then QA. Do not skip the looking.
05

Iteration log

Every pass began by screenshotting at 1440px and 390px and actually looking at the images. Nearly everything below was invisible in the code and obvious in a picture.

  1. Pass 0
    Probe
    • Derisked the whole build with a 60-line probe page before writing any of the site: confirmed three.js loads from jsdelivr over file://, the simplex noise compiles under SwiftShader, and the sphere renders non-black.
    • First render was a rocky moon: the land threshold sat at the noise mean, so ~70% of the surface was land and the oceans were islands.
    • Caught the atmosphere lighting the wrong limb. dot(-N, uSun) glows the anti-sun side; back-face rendering doesn't flip the normal for you.
  2. Pass 1
    Structure
    • The [hidden] bug, twice. .btn{display:inline-flex} and .deck-main svg{display:block} both outrank the UA's [hidden] rule, so the nav rendered two CTAs and all three console panels stacked on top of each other, adding 1,000px of dead height. Fixed with one !important.
    • Straight-line terminator. The sun was perpendicular to the view, bisecting the disc with a razor edge. Moved it to ~53° off axis and widened the day ramp.
    • Every landmass was tan. City lights were gated on 1.0-day, which stays nonzero across most of the visible disc. Re-gated on true night.
    • Cities were a smear, not sparkles. Six octaves of noise at base frequency 44 aliased below pixel size. Cut to two octaves.
    • A dark ring around the planet. The atmosphere's alpha was eating the page background without adding light. Switched every additive material to add RGB and leave destination alpha alone.
    • HUD frame was pinned to the viewport edge at 32px while the content column started at 112px, a near-miss alignment that read as sloppy. Re-hung the frame on the content grid.
    • Label flipping used one shared width for every label, so the longer region names ran off the right edge. Measured per label, and again after document.fonts.ready since mono metrics shift when the webfont lands.
    • Readout chips filled with solid --space showed as lighter boxes over the star field. Swapped to a tinted blur.
    • Star field sat at radius 70 with a 38° frustum, so most of it was off camera. Pulled to 42 and raised the count to 2,600.
  3. Pass 2
    Depth
    • Added a telemetry block (uptime, stations, MTTD) with animated bar meters to the hero's empty left field. Product specs wearing mission-control clothes.
    • Added fixed column rails across the page, a drafting-table grid the sections sit on. Made the panel sections translucent so the rails read through them.
    • Reused the hero HUD's corner-bracket motif as a hover lock-on for the instrument cards, so the reticle language repeats instead of appearing once.
    • Staggered the card reveal: the grid frame lands first, then each cell's contents rise at 70ms intervals.
    • Wired the alert reticle into the node labels, so a degraded region's DOM label pulses amber in sync with the sprite.
    • Dependency arcs in the console now flow, the hot path runs faster, and the saturating node breathes. Added a legend beside the section title, which also filled the void to the right of the heading.
    • The ground-track satellite was parked at 0,0 because the circle never got a cx/cy. Now it flies its own path via getPointAtLength and trails the pass behind it.
    • Themed the scrollbar, added a T+ clock to the mission log console, and set the final CTA's orbit rings turning on a 120s cycle.
    • Mobile regressed. The new telemetry block made the hero taller and shoved the planet straight through the headline, and the node labels had no guard on narrow screens so they sat on the copy. Re-hung the planet on a fraction of hero height instead of a fixed world offset, gave the copy a 39vh top pad, and added a ceiling guard for labels.
  4. Pass 3
    QA
    • Verified 390px: no horizontal overflow, tap targets at or above 44px, type scales without breaking.
    • Checked prefers-reduced-motion: the scene renders one static frame and never starts a loop, the log prints instantly, the clocks stop.
    • Confirmed title, description, og tags, inline SVG favicon and theme-color on both routes.
    • Console clean on both routes, deployed, and re-shot against the live URL to confirm production renders identically.
06

Attribution

This site was designed and built entirely by Claude Opus 4.8. Concept, copy, art direction, the shader, the layout, all three iteration passes and the deploy. No part of Perigee was made by another model.

The showcase it belongs to was started on Claude Fable 5, which built the other sites in the series before its usage credits ran out mid-run. The remaining sites, including this one, were finished on Claude Opus 4.8 with the user's approval. Readers of a showcase deserve to know what produced what, so: other sites in this showcase are Fable 5's work. Perigee is Opus 4.8's.

Design and build
Claude Opus 4.8, start to finish
Copy
Claude Opus 4.8. No lorem ipsum, no invented testimonials, no earnings claims.
Other showcase sites
Claude Fable 5, built before its credits ran out
Product
Perigee is fictional. It isn't for sale and never existed.
Third-party code
three.js 0.160.0 (MIT). 3D simplex noise after Ashima Arts / Stefan Gustavson (MIT).
Type
Archivo and IBM Plex Mono, both SIL Open Font License, via Google Fonts