How this site is built: Astro, one tokens file and an agent that follows a brief

A static Astro site with native CSS, a three.js sphere and a cursor field. What is in the stack, how I worked with Claude Code, and how I got the LCP from 2.6 to 1.66 seconds.

The hydroburner mark made of amethyst crystals, on a black background

This site is a static Astro project with native CSS and a few small libraries. No React, no Tailwind, no GSAP. The one heavy dependency is three.js, and it is there only for the sphere in the side panel.

I moved it from Framer to my own code and updated it at the same time. I art-directed and wrote the brief. Claude Code wrote most of the code, session by session.

My portfolio used to live on Framer. It did the job, but it was not flexible enough, and many things I wanted were not possible there. Once I learned Claude Code, I rebuilt the site on my own stack. There is no subscription, I control every layer, and I wanted to understand three.js properly. I also wanted a living, responsive site that pulls you in, a project of its own and not a gallery of pictures and stories.

Brief first, then sessions

Before any code I wrote a brief: stack, art direction, motion values, page by page, performance targets. Then I split the work into 12 sessions. Each one has a clear goal and adds a line to a progress log. The log also holds open questions to me.

The rules the agent must not break sit in a CLAUDE.md file in the repository:

  • orange only in the sphere, the cursor field, the preloader and text selection;
  • every duration, curve and spring comes from the tokens, with no magic 0.35s;
  • every interactive element has hover, press and focus, and responds in 100 ms or less;
  • numbers from a case live only in its meta.yml;
  • no invented content, only real facts, and nothing is published without a human review.

One source of tokens

Colors, type, spacing, durations and springs live in JSON files exported from my design system. A script turns them into CSS custom properties before every dev run and build, so a value is typed once. Motion values sit in one file that both the TypeScript motion module and the token script read.

:root {
  --dur-fast: 200ms;
  --ease-hb-out: cubic-bezier(0.12, 0.23, 0.5, 1);
}

Typography uses clamp(), springs are exported as CSS linear(). CSS is split into layers from reset to motion, and the motion layer works only under prefers-reduced-motion: no-preference.

Monochrome and one accent

The base of the site is monochrome, so the content stays in front. Orange is the accent that brings it to life: it appears in the glyph field under the cursor, the particles of the sphere, the preloader and text selection.

Stack, and what is left out

  • Astro, static output, content collections with schemas. Every case, project and note is a folder with a meta.yml and one text file per language. The build fails when a case or project has no pair of languages.
  • Native CSS: @layer, custom properties, nesting, container queries.
  • Motion, the vanilla build of the engine behind Framer Motion, so springs and curves match the old site.
  • Lenis for smooth scroll with a duration of 0.8 s. It is off on touch screens and with reduced motion.
  • three.js for one scene.
  • Playwright for tests.
Four steps left to right: docs/ds/tokens.json, scripts/build-tokens.mjs, src/styles/tokens.css, components
One tokens file, typed once

Three labs, so I tune by eye

The hardest part of working with AI is the final polish, especially for a designer who cares how a thing looks and feels. So with Claude I built three labs, one for the cursor, one for the preloader and one for the 3D sphere. I tuned every value by eye, handed the result to the agent, and it applied exactly that. Ordinary vibe coding stops at what the agent produced. I have a fine tune that I control completely, so the finished product looks the way I want.

A home-made review interface helped even more with the polish. Right on top of the live site I leave pinpoint notes on any element, and the agent receives them together with the element’s location on the page. I never have to describe in chat where exactly a button or a paragraph is, so any small detail can be brought to perfect.

The look of the sphere, the cursor and the preloader is not in the code. Each one has a lab, a standalone page that runs the real module, and I tune values there with sliders.

The sphere lab has 41 shapes and sets the shape for every page, case and project, plus tilt, rotation, particle count and size, cursor physics, colors by theme and the speed of morphing. The cursor lab has 29 element types from buttons to slides, and each type gets one of seven reactions: none, halo, orbit, spotlight, underline, corners or ripple. The preloader lab sets the rhythm of 9 beats, the easings and the finale.

Each lab exports a JSON file, and a script validates it and writes the config the site reads. So the lab is the source of truth, and nobody edits the config by hand. The cursor lab also has a pinned mode: the cursor stands on a chosen element by itself, so I can move sliders without keeping the mouse there.

Four steps left to right: the three labs, tuning with sliders and a live preview, an export to JSON applied by one script, the site with exactly the tuned values
From a slider in a lab to the live site
The cursor lab window: element samples on the left, the element list with a mode per type on the right, the cursor pinned on the Book a call button with an orange glyph effect around it
The cursor lab: element types on the left, settings on the right

The sphere and the cursor field

The sphere is 1600 particles by default on a three.js Points object with a shader sprite. Physics runs on the CPU, and the renderer sleeps when nothing moves and when the sphere is off screen. three.js is about 131 KB gzip and loads with a dynamic import() only when the sphere is visible. A static AVIF frame, rendered from the same scene, is the fallback.

Five particle shapes from the sphere lab on a dark ground: sphere, torus, double helix, hydroburner mark, cube on an edge
A few of the 41 sphere shapes

The cursor field is a Canvas 2D port of a Framer component, with no libraries. Its loop sleeps when the cursor stays still for more than 0.7 seconds and all points have faded. The trail lies under the glass islands. The glow, ripples and orbits of the hovered element are drawn on canvases above the content, because under the glass they came out muted and blurred.

At first I used Astro’s ClientRouter, with the field canvas and the sphere persisting across page transitions so they would not restart. Later I replaced the component with its meta tags and my own router-init.ts, so that the router code would load after the first paint. The page transition is 0.2 s out and 0.2 s in, and the case cover morphs into the hero of the next page.

Performance: LCP from 2.6 to 1.66 seconds

My first Lighthouse mobile run gave an LCP of 2.6 s on the home page and 2.7 to 3.0 s on a case, against a target of 1.8 s. The causes were 20+ JavaScript modules, a Cyrillic font preloaded on the English version, a hidden light or dark image variant that loaded as eager, and separate requests for image placeholders.

Fixes:

  • all client code starts after the load event and the LCP frame (boot.ts);
  • the font files are subsets: Latin 33.8 to 23.1 KB, Cyrillic 15.9 to 10.9 KB;
  • a picture with a light and dark pair loads only the needed theme;
  • arrows are SVG, so one font subset for symbols is no longer needed.

On October 10, 2026 the home page had an LCP of 1.66 s and a case page about 2.0 s (Lighthouse mobile with simulated throttling, median of 3 runs, production preview). Performance was 98 to 99, and accessibility, best practices and SEO were 100. CLS and TBT were 0.

Lighthouse mobile report header for the home page: Performance, Accessibility, Best Practices and SEO at 100, FCP 1.1 s, LCP 1.7 s, TBT 0 ms, CLS 0
Lighthouse mobile on a local production build of the home page

The preloader

On the first visit in a session the mark assembles, then 9 beats switch through frames of the mark in different materials, and the finale zooms into the center ball and lands in the logo in the navigation. Any input skips it.

The first version put the frames in the DOM as images, and the browser counted them as LCP candidates: 3 s on a throttled phone profile against 0.17 s without the preloader. Now the module hands the frames over as canvases, and the LCP is back to 0.18 to 0.32 s.

Nine preloader frames in a row: the mark in deep water, storm clouds, magma rock, moss, fossil amber, leaf with dew, pink salt, coral and lapis
Frames of the preloader, one per beat

What went wrong

The header buttons broke after page transitions: both theme icons showed on the left. Two components declared @layer components in their own styles. After a transition Astro re-inserted those styles, the line that sets the layer order for the whole site ended up after them, and components landed below reset. A plain svg { display: block } from the reset started to beat the rule that hides the moon or the sun. The fix is a separate inline style with the layer order, placed right after the theme script, which the router leaves in place. There is a test for it now.

The sphere stopped working on my machine for another reason. npm run build wrote into the same node_modules/.vite as the running dev server and broke its optimized dependencies, so three.js returned 504 and only the static picture remained. The build has its own cache directory now.

What I would flag

  • Safari is not checked yet, and AVIF with alpha in the preloader frames is the first thing I will look at there.
  • The cursor field goes to sleep when the cursor is still, to save battery. On the old Framer site the spot under a still cursor kept burning, and I decided to live with the difference.

Next note

All notes
October 18, 2026 · AI and agents7 places I use AI agents in design work, and where I stop