The marketing site for TreeMap, the open-source disk-space visualizer.
One long scroll, two colours, and a single moving background. The page alternates between moments (transparent, the shader field runs full bleed behind one idea) and chapters (opaque, text sits still on a solid surface). Between them sit two stages: a sticky canvas where a real squarified treemap builds itself as you scroll, and a sticky window that wipes between five of the app's views.
Built with Vite, React, TypeScript, GSAP ScrollTrigger and a hand-written WebGL1 shader. Five runtime dependencies total.
Open Terminal and paste these two lines:
cd "$HOME/Desktop/Claude Code/TreeMap Landing Page"
npm install && npm run devThen open http://localhost:5199.
To run every gate that has to pass before this ships:
npm run verifyTen checks: TypeScript, the Vite build, the squarify and spring property tests, the house rules (no glassmorphism, no emoji, no banned fonts, no em dashes in copy), and four runtime checks driven through a real browser against the dev server (contrast across the whole page, a clean console over a full scroll sweep, no sideways scrolling at 375px, and a reduced-motion pass with nothing left hidden). It exits non-zero if any fail, so it can gate a deploy.
Safari is checked separately, because this project can only drive Chromium
directly. scripts/webkit/ builds a real WKWebView and samples the
composited pixels; see its README for what that can and cannot prove. Last run
on Safari 26.6.2: worst case --paper 9.32:1, --paper-mid 4.77:1,
--azure 3.09:1, within one 8-bit unit of the model.
Three rules hold the whole page together.
Nothing pins, and nothing hijacks the wheel. Stages are position: sticky
in CSS. GSAP only reads scroll progress, it never takes over scrolling. There
is no ScrollSmoother. That removes the entire class of pin-spacer measurement
bugs the previous version fought with, and the page scrolls natively on a
trackpad, a mouse wheel and a phone.
Every scroll animation is a pure function of scroll position. No effect is
triggered by an event, so scrubbing backwards un-builds the treemap in the
same order it built, and a page reloaded halfway down renders the correct
frame immediately. useScrollProgress in src/lib/motion.ts also settles at
the endpoints on onLeave / onLeaveBack, so a fast flick can never strand a
stage half-built.
Nothing fades in. Scroll motion here means a build, a wipe, a draw, a dolly or a count. The only entrance animation on the site is the hero headline's clip-path wipe on first paint.
src/lib/wavesShader.ts is a 21st.dev Shader Builder "Waves" recipe, used
verbatim: a fullscreen triangle in a plain WebGL1 context, no library. The
loop pauses when the tab is hidden and device pixel ratio is capped at 2.
The recipe's four source colours are warm (near-black plum, dusty rose, peach, cream) but the shader applies a 2.0x saturation lift and a 2.27 radian hue rotation after them. That lands the wave crests on #4487ff. The page's azure is that measured value, not a separate brand pick, so the UI and the field behind it are literally the same blue.
The recipe's palette runs dark at the bottom of the viewport to cream at the top, so the raw field is near-white across the top of every screen. Measured against the page's ink that is 1.03:1 at 20% down. The recipe also runs grain at full strength, so a single pixel can spike another 0.175 brighter.
Two layers fix it, and both were solved rather than eyeballed.
A brand tint, multiplied in by the shader itself. BRAND_TINT is applied
as the fragment shader's last operation, after grain. Multiply is the whole
trick: a wash is a veil over the colour, multiply is a filter on it, and
alpha-blending anything dark over white passes through neutral grey no matter
which hue you pick. Multiplying white by a mid blue produces that blue, so the
field composites at 73 to 92% saturation instead of reading as grey texture.
This started as a CSS mix-blend-mode: multiply layer and moved into the
shader, because the CSS version was an untestable single point of failure.
Every engine parses mix-blend-mode, so an @supports query can never guard
a composite-level failure, and the measured failure mode was --paper-mid at
3.95:1 and --azure at 2.56:1. The shader version is verified bit-identical
(node docs/design/check-tint-equiv.mjs, max per-channel difference
0.000000000000) and costs one fewer full-screen composited layer.
Then .field-wash, a single-hue alpha ramp from 0.46 at the top to 0.24 at
the bottom. Each stop is the smallest alpha that still clears 7:1 for --paper
against the brightest sample of the tinted field at that height, across six
time slices, with a full grain spike added.
Verified worst case anywhere on the open field: --paper 9.07:1,
--paper-mid 4.73:1, --azure 3.01:1 (large text only). --paper-dim cannot
clear 4.5:1 over the field at any setting that leaves it visible, so index.css
lifts dim ink to --paper-mid inside any .moment or .stage.
If WebGL is unavailable the canvas is marked data-field="off", the wash goes
with it, and the page is flat --void at 17.9:1 with no seam anywhere.
If you want the field louder, lower the alphas in .field-wash or lighten
BRAND_TINT. Contrast falls with either, top of the viewport first. Re-run
docs/design/solve-scrim2.mjs, then emit-field.mjs, then the contrast probe
against the live page before shipping the change.
src/
lib/
wavesShader.ts the WebGL1 field, recipe verbatim
motion.ts useScrollProgress, easings, range/window4/smoothstep
squarify.ts the same layout algorithm the app uses
demoData.ts a deterministic fake scan
github.ts repo links and the star count
components/
scene/WavesField.tsx canvas host, pauses when the tab is hidden
ui/Wordmark.tsx, Marks.tsx every icon on the site, four of them
sections/ one .tsx and one .css per section
index.css the design system: tokens, type scale, surfaces, controls
Each section owns its own stylesheet. index.css holds only what more than
one section needs.
Deliberately absent, because they read as templated: glassmorphism, decorative gradients, gradient text, sections that fade in on scroll, badges above headlines, icon-box grids, cursor-following beams, hover states that change opacity, grain overlays, Inter, Space Grotesk, serif accents, and emoji.
Type is Geist and Geist Mono. Tracking is size-specific, tighter as type
grows. Spacing comes from one 4px scale (--s1 to --s11) and nothing sits
off it.
The site deploys to Vercel from main. Push with GitHub Desktop and Vercel
picks it up. Live at https://tree-map-landing-page.vercel.app.