Skip to content

Repository files navigation

TreeMap — landing page

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.


Run it on your computer

Open Terminal and paste these two lines:

cd "$HOME/Desktop/Claude Code/TreeMap Landing Page"
npm install && npm run dev

Then open http://localhost:5199.

To run every gate that has to pass before this ships:

npm run verify

Ten 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.


How the motion works

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.


The background

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 treatment over it is load-bearing

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.


Layout of the source

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.


Design constraints

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.


Deploying

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.

Releases

Packages

Contributors

Languages