Build guide

How this
was made

Sidechain is a fictional product built as a design showcase. This page is the honest version: the art direction reasoning, the techniques worth stealing, the bugs that nearly shipped, and exactly which model did the work.

Built by Claude Opus 4.8 Vanilla HTML, CSS, JS three.js 0.160.0 Zero image assets
01

Concept

The fiction. Sidechain is version control for music producers. Producers already live the problem git solved twenty years ago, they just solve it with filenames: final_v9_FINAL_2.wav. So the product is git for sessions, and the copy speaks to someone who already knows what a stem and a comp are. No explaining. The pink marquee halfway down the page is just a list of real filenames from real hard drives, which is the whole pitch in one strip.

Why Y2K chrome. Dance music's visual memory is 1999: liquid metal, club flyers, CD-ROM menus, chrome wordmarks bevelled to within an inch of their life. It's the native aesthetic of the audience, and it's unfashionable enough right now to be distinctive. The rule was that Y2K is a style, not an excuse. The type scale, the 8px rhythm and the grid stay sharp underneath the gloss. Loud surface, disciplined skeleton.

Why these colours. The field is near-black (#0E0E12) because chrome is a mirror and a mirror needs something dark to be bright against. Silver does the work, and lime (#CCFF00) and hot pink (#FF3D9A) appear only as light: rim reflections on the knot, the playhead, a lit clip, the merged branch. They're never decoration. If a colour isn't a light source, it isn't there.

Why this motion. Producers work to a grid, so everything snaps rather than drifts. Entrances use an exponential ease (cubic-bezier(.16,1,.3,1)) that covers most of the distance immediately and settles. The one piece of continuous motion that earns its keep is the playhead, because that's the product.

02

Technique breakdown

Procedural chrome envmap

Chrome needs something with contrast to reflect

This is the whole hero. A metal with metalness:1, roughness:0.045 shows you nothing but its environment, so the environment is the material. The first attempt used a bright studio softbox and the knot rendered as glossy white porcelain. The fix was inverting the instinct: a chrome room is mostly dark, cut with a few blazing hard-edged bands. The near-instant dark stop at 0.474 to 0.5 is the horizon, and that hard light/dark edge wrapping the form is what the eye reads as metal. Lime rides just above it, pink just below, so both wrap the silhouette as it spins. Drawn to a 2048x1024 canvas, then run through PMREMGenerator.

// mostly dark, with hard-edged blazing bands. a bright
// softbox here renders the knot as white plastic.
const STOPS = [
  [0.115,'#ffffff'], [0.178,'#4a525f'],  // band, then drop
  [0.400,'#ffffff'], [0.410,'#252a34'],
  [0.474,'#05050a'], [0.500,'#030307'],  // the horizon
  [0.508,'#ffffff'], [0.532,'#b9c1d0']   // glint under it
];
blob(g, 360, 432, 300, 44, '#CCFF00', 1);   // lime above
blob(g, 1340, 585, 340, 48, '#FF3D9A', 1); // pink below

const envTex = new THREE.CanvasTexture(makeEnvCanvas());
envTex.mapping = THREE.EquirectangularReflectionMapping;
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromEquirectangular(envTex).texture;
Specular bevel on live text

The elevation angle is the whole trick

The headline is real selectable text: a chrome gradient via background-clip:text, plus an SVG feSpecularLighting bevel. The first build set elevation="64" and the glyphs came out flat white. At a high angle the light hits the letter's flat interior nearly head-on, so N·H is about 0.97 and the whole plateau lights up, drowning the gradient. At 22 degrees the interior falls to roughly 0.8218, near zero, and only the blurred edge slopes catch light. That's the difference between a bevel and a wash.

<!-- low elevation: the flat interior stays dark, only the -->
<!-- blurred edge slopes catch the light. 64deg floods it. -->
<filter id="chrome-bevel">
  <feGaussianBlur in="SourceAlpha" stdDeviation="2.6" result="blur"/>
  <feSpecularLighting in="blur" surfaceScale="4"
      specularExponent="18" lighting-color="#fff" result="spec">
    <feDistantLight azimuth="228" elevation="22"/>
  </feSpecularLighting>
  <feComposite in="spec" in2="SourceAlpha" operator="in" result="specClip"/>
  <feComposite in="SourceGraphic" in2="specClip" operator="arithmetic"
      k1="0" k2="1" k3="0.85" k4="0"/>
</filter>
Chromatic rims

Offset has to beat blur, or you get mud

The lime and pink edges are two pseudo-elements sitting behind the glyph, offset in opposite directions. They live on a wrapper, deliberately outside the filtered element: put them inside and the blurred colour feeds into SourceAlpha and mushes the specular. The first version used blur(9px) against a 2.8px offset. When blur exceeds the offset both colours bleed in every direction, overlap, and average out to a dirty salmon halo. Offset larger than blur is the rule, and then each colour only escapes on its own side.

/* offset MUST exceed blur or lime + pink average to mud */
.ct { position: relative; display: inline-block; isolation: isolate; }
.ct::before, .ct::after {
  content: attr(data-text);
  position: absolute; inset: 0 auto auto 0; z-index: -1;
}
.ct::before { color: #CCFF00; transform: translate(-.038em,-.032em); filter: blur(3px); }
.ct::after  { color: #FF3D9A; transform: translate(.036em,.032em);  filter: blur(3px); }
.chrome { background: var(--chrome-ramp); -webkit-background-clip: text;
          color: transparent; filter: url(#chrome-bevel); }
The timeline demo

One rAF loop drives the playhead and every clip

The feature demo is a mock DAW: five tracks, sixteen bars, clips positioned as percentages. A single loop moves the playhead and toggles a .lit class on any clip whose bar range contains the current position. Early on the lit state flooded the whole clip lime and it read as a brick, so now only the waveform lights up. Waveforms are generated from a seeded PRNG, so a clip looks identical on every reload. The version pills re-render the arrangement, which is the actual product promise: rewind and the grid changes.

function paint(pos) {                       // pos = 0..16 bars
  head.style.transform = 'translateX(' + (pos / BARS * laneW) + 'px)';
  posEl.textContent = fmt(pos);              // "3.2.4"
  TRACKS.forEach(function (t) {
    var row = rows[t.id], any = false;
    row.querySelectorAll('.clip').forEach(function (c) {
      var lit = pos >= +c.dataset.s && pos < +c.dataset.e;
      c.classList.toggle('lit', lit);
      if (lit) any = true;
    });
    row.classList.toggle('on', any);           // track LED
  });
}
The version graph

A horizontal line has a zero-height bounding box

The graph edges draw themselves in on scroll: measure each path with getTotalLength(), push it into a custom property, and transition stroke-dashoffset to zero with a stagger. The bug worth knowing: the entire main trunk rendered as nothing. An SVG linearGradient defaults to objectBoundingBox units, and a perfectly horizontal path has a bounding box of zero height, which collapses the gradient and paints nothing at all. Only the curved branches survived. gradientUnits="userSpaceOnUse" fixes it. The dashed open branch also needed its own class, because a CSS stroke-dasharray silently overrides the presentation attribute and eats the dash pattern.

<!-- objectBoundingBox (the default) collapses on a -->
<!-- horizontal path: zero height, so nothing paints. -->
<linearGradient id="gEdge" gradientUnits="userSpaceOnUse"
    x1="0" y1="0" x2="1000" y2="0"> ... </linearGradient>

// measure once, let CSS do the animating
edges.forEach(function (p, i) {
  p.style.setProperty('--len', p.getTotalLength().toFixed(1));
  p.style.setProperty('--gd', (i * 90) + 'ms');
});
/* .gedge { stroke-dasharray: var(--len); stroke-dashoffset: var(--len) }
   .graph-wrap.in .gedge { stroke-dashoffset: 0 } */
03

Asset pipeline

There are no assets. No images, no HDR files, no icon fonts, no textures. Every pixel that isn't type is generated in code, which keeps the whole page under a few hundred KB excluding fonts.

The environment map is the interesting one. Reflective metal normally wants an HDR photograph of a real room, and the brief ruled that out. Instead a 2048x1024 canvas is painted with a vertical gradient of hard-edged bands, five dark vertical columns and three bright pillars to break the bands into streaks, and six radial blobs for the light sources. That canvas becomes a CanvasTexture, gets tagged as EquirectangularReflectionMapping, and PMREMGenerator pre-filters it into the cube-UV format three.js samples for roughness. Total cost: about 40 lines and zero network requests.

Everything else: the logo mark is a hand-drawn SVG path of a sidechain ducking envelope (signal drops, recovers, drops again) and doubles as the favicon via a data URI. Feature icons are hand-drawn inline SVG. Clip waveforms come from a seeded mulberry32 PRNG shaped by a sine envelope, so they look like audio rather than noise and never change between reloads. Grain is an feTurbulence data URI. The cursor trail is a 2D canvas drawing a tapered polyline tinted by point age.

Verification. Every pass was screenshotted headless with Playwright and read back as images, including element-level shots at 2x. That's how the invisible graph trunk and the two-line logo wrap were caught. WebGL renders fine in headless Chromium under SwiftShader, so a black canvas is a bug, never an excuse.

04

Recreate it

Paste this into Claude. It's structured Role, Task, Context, Format, Constraints, Examples. Swap the product and the palette and the same skeleton holds.

Prompt
ROLE
You are an art director and creative developer. You write hand-built
vanilla HTML, CSS and JS. No frameworks, no build step, no UI kits.

TASK
Build a single-page marketing site plus a /guide route for a fictional
product. One signature "money shot" in the hero that carries the page.

CONTEXT
Product: a version control tool for music producers.
Audience: working producers who already know what a stem and a comp are.
Aesthetic: Y2K chrome, liquid metal, club flyer futurism.
Palette: bg #0E0E12, chrome #F8F8FF to #9CA3AF, lime #CCFF00, pink #FF3D9A.
Type: Unbounded for display, Space Grotesk for body, via Google Fonts.

FORMAT
index.html, styles.css, main.js, guide/index.html. Sections: nav, hero,
proof strip, features (one live interactive demo), product showcase,
pricing (3 tiers), final CTA, footer.

CONSTRAINTS
- Money shot: a three.js torus knot, metalness 1, roughness 0.05, lit ONLY
  by a procedural environment map generated in code. No HDR files.
- The envmap must be mostly DARK with hard-edged bright bands and a sharp
  horizon. A bright studio envmap renders metal as white plastic. Put a
  lime source just above the horizon and a pink one just below so both
  wrap the silhouette as it spins.
- Chrome headline: real text, background-clip gradient with a hard dark
  stop at 50%, plus an feSpecularLighting bevel at LOW elevation (~22deg).
  High elevation floods the glyph interior white.
- Motion: named cubic-beziers only, no default ease. Exponential easing on
  entrances. Everything decorative wrapped in prefers-reduced-motion.
- Copy: contractions, no em-dashes, blunt and peer-to-peer. No buzzwords,
  no testimonials, no invented statistics. Product specs are fine.
- Must render from file:// as well as https://, so inline the module
  script and keep three.js on an importmap.

EXAMPLES
Voice: "Branch a beat. Fork the session and try the darker bass without
touching the version your vocalist is singing to."
Not: "Unlock next-level collaboration and empower your workflow."

PROCESS
Build it, then run three screenshot passes. Actually LOOK at the images
each pass. Pass 1 fix structure, pass 2 add depth, pass 3 QA mobile at
390px, the guide route, reduced motion and the console. Be brutal in
your own critique. A clean console proves nothing about pixels.
05

Iteration log

Pass 1
Structure
  • The chrome headline wasn't chrome. It rendered flat white with a salmon halo. The bevel filter was lighting the glyph's flat interior at elevation 64, flooding it white and drowning the gradient. Dropped to 22 so only the edge slopes catch light.
  • The lime and pink rims were mud. blur(9px) against a 2.8px offset meant both colours bled everywhere and averaged to dirty salmon. Made the offset exceed the blur.
  • Rebuilt the chrome ramp around a hard dark horizon stop. Without a real dark band the glyphs read as white plastic.
  • The graph's entire main trunk was invisible. Horizontal paths have a zero-height objectBoundingBox, which collapses a linearGradient to nothing. Switched to userSpaceOnUse. The horizontal pink connector between two branch nodes was missing for the same reason.
  • The open branch lost its dashes because CSS stroke-dasharray overrides the presentation attribute. Split it into its own class that fades in rather than draws in.
  • The knot was cropped into an unreadable blob. A (2,3) torus knot's real half-extent is about 1.8 units, not radius plus tube. Pulled the camera to z=8.
  • 308px of horizontal scroll at 390px. A six-column 1fr logo grid kept its min-content floor and refused to shrink. minmax(0,1fr) plus breakpoints. It was hidden behind body{overflow-x:hidden}, which is masking, not fixing.
  • Also: nav links were invisible over the bright knot (added a scrim), the timeline lit whole clips lime instead of their waveforms, the logo wall wrapped 4+2, the brand mark read as a "W" at 30px, section headings were flat white, and em-dashes had crept into the title and og tags.
Pass 2
Depth
  • Gloss sweep on every button. A hard specular bar rakes across the metal on hover and focus. Lime and pink for the ghost variant.
  • Scroll progress bar in the nav, chrome into lime into pink, driven off a rAF-throttled scroll handler.
  • Comment pins on the timeline, pinned to a bar with a hover note. It ties the "notes on the grid" feature to the live demo and gives the panel the pink it was missing.
  • Card top-edge lights that widen and shift lime to pink on hover, plus flyer-style index numbers.
  • A time axis under the version graph with real dates, and a slow specular sweep across the highlighted pricing tier.
  • A giant chrome wordmark closing the footer, masked to fade out at the baseline.
  • Fixed a bug this pass created: the shine sweep forced position:relative onto every tier child, which out-specified .tier-tag's absolute centring. The tag jumped right and pushed the price out of alignment. Moved the sweep to a negative z-index inside an isolated stacking context. Tiers are now equal height with the CTAs pinned to the bottom.
Pass 3
QA
  • Mobile at 390px: no horizontal scroll, tap targets at or above 44px, the knot repositioned above the fold and scaled down, the timeline gutter tightened to 62px.
  • Anchored sections were landing under the fixed nav. Added scroll-margin-top.
  • Reduced motion: the knot renders one static frame instead of running rAF, marquees stop, the playhead parks on a lit bar, the cursor trail is removed entirely.
  • Guide route built, styled to match, screenshotted and verified live.
  • Console clean at both viewports, meta and favicon present, then deployed and re-shot against the live URL.
06

Attribution

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

The wider showcase it belongs to was started on Claude Fable 5, which built the other sites in the set. Fable's usage credits ran out partway through the run, and the remaining sites were finished on Opus 4.8. So the showcase has two authors, split cleanly by site rather than mixed within one. This one is Opus 4.8's from a blank folder.

Sidechain is a fictional product. The studios in the logo wall, the session history in the graph and the notes on the timeline are all invented for the showcase. There are no real customers, no testimonials and no performance claims anywhere on the page, by design.