How this site is built
A tour of the brush engine, the ink that follows your scroll, the hand-built maps, the woodblock sea, the Japanese patterns and the fabric they ripple through. Every demo below is live, so pull the sliders.
The site is built with Astro, which turns each page into plain HTML and CSS at build time, plus a few small TypeScript modules. Nothing runs a framework in your browser, there's no animation library and there are no images for the ink. Everything you see drawn is generated by about a thousand lines of code, and the timeline page loads in about 87 KB compressed.
01A brush made of code
The circle around my photo is an ensō, the brush circle from Japanese calligraphy. I wanted it to look painted, not drawn by a computer, so it's built the way a real brush works: a round tip pressed onto paper again and again as it moves.
The code walks around a slightly wobbly loop and stamps a small disc of ink at every step. Pressure changes along the way, so the stroke swells and thins. Near the end the brush "runs out of ink": the solid stamps stop and a handful of hair clumps carry on alone, each giving out at a different point. That's what makes the dry, streaky tail.
Randomness comes from a tiny seeded generator (mulberry32), not Math.random(). A seed always produces the same sequence of numbers, so the stroke looks identical on every visit and every device. It also means the stroke can be rebuilt instantly when you switch to dark mode, just in a different ink color.
// Pressure: a firm press, a wandering middle, then the brush lifts off
let p = (t < .05 ? .75 + t * 8 : 1.1 - (t - .05) * .35) + press(t * 3) * .18;
if (t > .62) p *= 1 - (t - .62) * 1.25;
if (t < dry) stamp(x, y, width * p / 2); // wet: a solid disc of ink
else clumps.forEach(c => c.alive(t) && c.stamp()); // dry: only some hairs touch paper
The stamps are stored as a list first and drawn second. Drawing the whole list at once gives a finished stroke. Drawing a few more each frame gives the paint-in animation you see when the page loads, with no extra code.
02Ink that follows your scroll
On the timeline, an ink stroke runs down the spine as you scroll, and each dot lights up the moment the ink reaches it. Keeping two separate animations in sync is fragile, so there's only one number here: the position of the ink's tip.
- The whole stroke is painted once, onto a tall canvas, with a little pool of ink at every dot.
- On scroll, a CSS
mask-imagegradient reveals the canvas down to the tip and fades out its last 26px, so the edge looks wet instead of cut. - The highlight asks the same question, "is this dot above the tip?", so the two can never drift apart.
const tip = tipPosition(); // one source of truth
ink.style.maskImage = `linear-gradient(#000 ${tip - 26}px, transparent ${tip}px)`;
entries.forEach(e => e.classList.toggle("inked", nodeY(e) <= tip));
One extra problem: the last few dots sit too close to the bottom of the page to ever pass the 38% line. Over the final stretch of scrolling, the tip blends smoothly toward the end of the spine, so it lands on the last dot exactly when you hit the bottom. The stroke is also repainted whenever the page changes height, for example when an image loads or you rotate your phone. A ResizeObserver on the timeline handles that, so the pools always sit under their dots.
The ruler at the top of the page follows the same number. Once my name has scrolled away it comes forward as part of the header, and a line of ink runs along it from today back to wherever you are, the horizontal partner to the spine.
03Maps from real geographic data
The location cards show real coastlines, not a map service or an image. I took Natural Earth's high-detail coastline and river data, projected it with d3-geo (a Mercator projection fitted to each card), and saved the result as SVG paths. That runs once, when the site is built. The browser never loads a mapping library.
A projected coastline has far more points than a 180-pixel card can show. Rounding to a tenth of a pixel and dropping points too close to their neighbor makes the paths several times smaller with no visible difference. Try it on the Hoboken map:
04One popover for everything
Every hover note on the site, from the ruler dots to the stats to the map pins, is the same small component. Any element gets one by adding data-tip and optionally data-tip-sub. A single shared element follows whatever you're pointing at. It works with the keyboard (focus shows it, Escape hides it), and on touch screens you tap to open and tap anywhere else to close.
The interesting part is placement. The popover centers itself above its target. If there isn't room above, it flips below. Near the sides it slides inward to stay on screen, and the arrow slides the other way so it still points at the target. Drag the chip around to watch it happen:
const x = clamp(cx - pw / 2, 8, W - pw - 8); // center on the target, but stay inside
const below = r.top - ph - gap < 8; // no room above? flip
pop.style.setProperty("--ax", cx - x + "px"); // the arrow keeps pointing at the target
05The flight path
The two flights between New Jersey and Japan follow a curve shaped like a route on an airline map. The timeline starts at today and goes back in time as you scroll down, so my first version had a logic problem: the plane flew forward while the years ran backward. Now each plane waits at its destination, flies back to where it came from as you scroll down, and makes the real trip when you scroll up. It always flies nose first, banking around whenever you change direction.
As you scroll, the plane's position comes from the browser's own geometry functions: getPointAtLength() gives the spot along the curve, and two points either side of it give the direction it should face.
Raw scroll progress moves at a constant speed, which feels mechanical. An ease-in-out curve makes the plane accelerate off the runway and slow down to land, and its size grows a little in the middle of the flight, as if it climbs toward you. Scrub it yourself:
06A woodblock sea
Under each flight there's a breaking wave, inspired by the look of Japanese woodblock prints and drawn with the same brush engine as the ensō. Getting it to look natural took several rebuilds. A wave with a lip stuck on top always looked like two shapes, so the one here is a set of nested stripes: each one rises up the back slope, curls over the crest and wraps around a hollow barrel, so the water reads as one continuous mass. Foam is a chain of overlapping dots with an ink rim, and they merge into one outlined shape with fingers that hook the way the wave is breaking.
It's expensive to compute and cheap to show. The wave is built once, only when its band nears the screen, and painted onto two canvases: one for the watercolor wash and one for the ink. After that, scrolling only moves a mask to reveal it behind the plane and deepens the wash, the same trick as the spine.
07Patterns from pure geometry
Traditional Japanese textile patterns are geometry, so they can be generated instead of drawn. Each one is a small function that returns a seamless SVG tile, run once when the site is built. Each timeline entry wears one chosen for what it means, and the same tile becomes the cap on its badge (when there's no logo to show), the wash behind its heading and the texture of its info box. The tiles are dyed in each theme's indigo and ship as CSS variables, about 4 KB compressed for all six in both themes. The generators themselves never reach the browser, except for the demo below.
The trickiest was bishamon kikkō, which groups hexagons in interlocking threes. The code takes every third hexagon on the grid and adds its two neighbors around a shared corner, which covers the plane exactly. It then keeps only the edges a single hexagon owns, chains them into one outline, and scales that outline toward the corner twice to nest it.
The hero's cloth at the top of the home page uses all six at once, in wave-edged bands with a thin seam between them, like a brocade. The About page has its own version with the five patterns of my career, in the order they happened, flowing the other way.
08A ripple through the cloth
Hover a card and ink spreads from where your pointer came in until the pattern fills it. Then the cloth moves: ripples travel out from your cursor, and the dye deepens where the fabric lifts. The spreading is a CSS mask with an animated radius. The movement needs real drawing, and it's done two ways. Seigaiha is simple enough to redraw scale by scale every frame. The other patterns have too much detail for that, so each is painted once onto an offscreen sheet, and each frame copies thin columns of that sheet, shifting the ones near the cursor up or down.
Move your pointer across this card, or tap it. Ink spreads from where you came in, then the cloth moves under your cursor.
for (let x = lo; x < hi; x += step) { // only the columns near the cursor
const d = Math.abs(x - pointer);
const f = Math.exp(-((d / reach) ** 2)) * amp; // strongest under the cursor
const wave = Math.sin(d * k - time);
ctx.globalAlpha = base + base * .9 * f * (.5 + .5 * wave); // the dye deepens as it lifts
ctx.drawImage(sheet, x, wave * f * strength, step, h, x, 0, step, h);
}
Frames run only while a card is hovered or still settling, and then the loop stops completely. In testing it held 60 frames a second even with the CPU slowed to a quarter of its speed.
A moving pattern behind text is a readability problem. Blurred shadows alone fade out before they cover the lines, so each line of text gets two tight rings of shadows in the card's own color, which makes the pattern stop just short of every letter. A registered CSS property scales the rings, so they stay small and invisible at rest and swell as the ink arrives.
/* a tight ring of shadows in the card's own color, then a soft edge */
text-shadow:
calc(1.8px * var(--hk)) 0 calc(1px * var(--hk)) var(--halo),
/* ...23 more around the letter, in two rings... */
0 0 calc(20px * var(--hk)) var(--halo);
09Keeping it fast and accessible
A portfolio full of effects is easy to make slow. I set a budget before adding anything and measured against it:
I also kept score. I ran Lighthouse and axe throughout the build, on every page, in both themes, on phone and desktop sizes, and treated a lost point as a bug to fix. That caught real problems: the ink once cost a phone most of a second of blocked time, which is why it now paints in small slices when the browser is idle. Scores can wobble by a point between runs, but the site holds 100 across the board.
- Motion is optional. With "reduce motion" turned on, the intro sweep, the paint-in, the pulsing pins, the flying items and the fabric ripple all turn off. The scroll-linked ink still works, because it only moves when you do.
- The keyboard reaches everything. Popovers open on focus as well as hover, J and K step through the timeline, and ⌘K jumps anywhere. The widgets use real buttons, switches and radio buttons, and changes like "Gift added" are read out to screen readers.
- Expensive work happens once. Strokes, waves and maps are computed and painted once, then revealed or moved. Scrolling only updates a mask and a few transforms. The one exception is the ripple, which draws only while you're hovering a card.
- Lazy where it counts. Project screenshots and logos load as you reach them, waves are built as they near the screen, and Hanzi Writer and its stroke data load only when the kanji pad gets close.
The budget also applies to attention. Some of what I built didn't survive seeing it on screen. For a while the nav wore the current section's pattern, and even softened with a crossfade it was too busy, so it went back to plain. I also built a floating island to replace the nav and win back screen height, and it worked, but the page felt better with the bar's spacing. Trying things for real is cheap now, so deciding what to keep matters more.