Vaidik Vadhavana
← Writing

How this site works

How the sand and Atlas are drawn.

This site is one page and a few small ones. Behind all of them there is sand, which moves to new lines as you scroll. Next to my name is Atlas, holding up a sphere, drawn in grain. I built all of it from scratch, and this post goes through how: the design rules first, then the sand, then Atlas. Why it looks this way at all is in the companion post, Finding a design language.

Everything here is live. The demos are the same code the site runs, cut down so you can change the numbers.

The design rules

The site has very few rules, and I hold them strictly. There is one accent colour. Borders are hairlines, corners are square, and nothing has a shadow. Text is set in Schibsted Grotesk; labels, dates and numbers are set in IBM Plex Mono with tabular figures, so columns of numbers line up.

Never hard-code a colour outside the tokens.

— the design rules

Every colour on the site comes from eight CSS variables, defined twice: once for the light theme and once for the dark. Components only ever say var(--ink) or var(--line). That is why the theme switch is a single attribute change on <html>, and why the panel below can render both themes at once. It sets data-theme on a box instead of the page.

light

  • --bgPage
  • --surfaceRaised
  • --inkText
  • --mutedSecondary text
  • --lineHairlines
  • --softQuiet marks
  • --accentThe one colour
  • --accent-fillAccent ground

dark

  • --bgPage
  • --surfaceRaised
  • --inkText
  • --mutedSecondary text
  • --lineHairlines
  • --softQuiet marks
  • --accentThe one colour
  • --accent-fillAccent ground
The eight tokens in both themes, read live from the stylesheet.
RuleValueWhy
Page width1120pxA 200px heading column, then the content
Reading width680pxAbout 70 characters a line
Touch targets≥ 44pxBig enough for a thumb on every control
Corners0With hairlines, square corners look like a technical drawing
Accent1 colourBlue marks the main button, section numbers, links in posts and whatever you point at
MotionOpt-out respectedprefers-reduced-motion stops every loop
The rules, as numbers.

No flash of the wrong theme

If the theme were set by React after the page loaded, a dark-mode visitor would see a white flash first. So a few lines run in the <head>, before anything paints:

js
try {
  var t = localStorage.getItem('theme')
  if (!t) t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
  document.documentElement.dataset.theme = t
} catch (e) {}

Phones first

Most people open a portfolio from a link on their phone, so I check every change at 320, 375 and 430 pixels wide before I look at a laptop. Doing this caught a bug that had been there for weeks. On phones, each section was a one-column grid of 1fr. A 1fr track will not shrink below its content, and the year-long activity chart is about 700px wide, so the whole page quietly became 740px wide on a 390px phone. Changing it to minmax(0, 1fr) fixed it.

Note

If a layout on a phone looks zoomed out, check for 1fr before anything else. minmax(0, 1fr) is almost always what you meant.

Hover effects only apply on devices that can hover, under @media (hover: hover). On a touchscreen, the first tap on a link would otherwise only trigger its hover state. The sand also ignores fingers. It reacts to a mouse, because a finger on a phone is scrolling and a tap would leave a mark. Atlas is the exception: a finger on him grows his halo, and lifting it bursts the halo into the sand.

The sand

In 1787 Ernst Chladni scattered sand on a metal plate and drew a violin bow along its edge. The plate rang, and the sand jumped off the parts that were moving and gathered on the lines that stayed still. Each note gave a different pattern. The background of this site is that plate, and the window is the plate.

A square plate has a shape for each pair of whole numbers m and n. Where the shape is zero, the plate is still:

math
// The plate on [0, 1]², for mode (m, n) and sign s = ±1
S(x, y) = cos(mπx)·cos(nπy) + s·cos(nπx)·cos(mπy)

Each frame, every grain does two things. First it takes a step towards the nearest still line. The still lines are the zero set of S, and a Newton step towards a zero set moves a point along the gradient by S·∇S / |∇S|², which is exactly where the plate would be zero if it were flat around that point. The grain only takes a fraction of that step per frame, capped at 24px, so it slides rather than snaps. Then it bounces by a random amount that grows with |S|, how hard the plate is moving under it. Grains on a still line barely move. Grains on a loud spot get thrown around until they land somewhere quiet. Try it:

Change m and n. The grains you see are the whole simulation.

Every section of the home page has its own mode, so scrolling plays a new note and the sand moves to new lines. When a mode changes, the plate blends from the old shape to the new one over 1.4 seconds, as S = (1 − e)·S_old + e·S_new with a cosine ease e = (1 − cos πt) / 2, so the sand drifts rather than jumps. Because the shape is a sum of cosines, the blend is still a smooth field with still lines of its own at every moment in between. On a desktop, pointing at a project borrows that project's mode.

Keeping it cheap

  • There are between 3,200 and 9,000 grains, depending on the window size, and each one is a single pixel.
  • The mode is sampled once on a 97×97 grid of values, with its two partial derivatives taken by finite differences, and each grain reads all three back bilinearly. The per-grain work is a dozen multiplications and no trigonometry.
  • Pixels are written straight into an ImageData buffer as packed 32-bit colours, with one putImageData per frame.
  • The loop stops when the tab is hidden. With reduced motion, the sand is placed already settled and only redraws when something changes.

Atlas

Atlas sits on the home page, kneeling on a plinth with a sphere on his back. There is no image file. He is described as maths, lit once, and then redrawn as grain on every frame.

Built from distances

The body is a signed distance function (SDF), which returns how far any point in space is from the surface: positive outside, negative inside, zero on it. A capsule is a distance to a line segment minus a radius. An ellipsoid is a squashed sphere. Atlas is about fifteen of these, for the torso, head, arms, legs and feet, joined with a smooth minimum. It is like min, except that within a radius k of where two shapes meet it replaces the crease with a polynomial fillet:

glsl
float smin(float a, float b, float k) {
  float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
  return mix(b, a, h) - k * h * (1.0 - h);
}

To draw it, a ray goes out from each pixel and marches forward by whatever distance the function returns. This is sphere tracing: the SDF promises no surface lies closer than that distance, so the step can never overshoot. When the distance drops below 0.001, it has hit him; after 160 steps or a depth of 7 units, it has missed. The camera is orthographic, so every ray is parallel and the drawing has no perspective, like a technical drawing. From there I take the surface normal from the SDF's gradient (six extra samples), ambient occlusion from five samples stepped out along the normal, and the shadow the sphere casts on his back from one ray-sphere test towards the light. All of that goes into a texture with three channels: light, depth and coverage.

That raymarch runs once per canvas size, not once per frame. Every frame after that only reads the texture. Switch between the passes below:

Light, depth and coverage are the texture. The sphere is missing from them because it is drawn fresh every frame.

Light into grain

This is random dithering. Every pixel gets its own random threshold from a hash of its position, and it lights up when the tone at that point is higher. Bright areas beat most thresholds and come out dense. Dark areas beat few and come out sparse. In the dark theme, lit grain gathers where the light falls. In the light theme it flips, and ink gathers in the shadows, the way a pencil drawing would.

Two details make it look like grain rather than noise. First, a pixel does not switch from off to fully on. It fades in over a ramp, so most grains are partly lit and the surface reads soft. Second, the thresholds move. If they were re-rolled every frame the whole thing would fizz like a dead TV channel. Instead, the pixel reads its threshold from a shifted position. The shift is four slow sine waves at different angles plus a domain-warped noise field, so the random pattern slides around coherently and the grain flows. Turn the ramp down to zero, then freeze it, to see both:

A narrow ramp gives hard dots. Still thresholds look printed; swaying ones look alive.

The sphere and the golden angle

The sphere is drawn exactly, with a ray-sphere intersection, in the same pass as the grain. That is why it can turn. On its surface there are 377 points. Each point is turned 137.5° around the axis from the one before it and placed a little further down. That angle is the golden angle, 360°·(1 − 1/φ) ≈ 137.508°, where φ is the golden ratio, and it is what sunflowers and pine cones use. Point i sits at height z = 1 − (2i + 1)/N and longitude 2.39996·i radians, which is known as a spherical Fibonacci lattice: no two points ever line up, so they pack evenly. As a side effect, spirals appear in two families, 21 one way and 34 the other. Both are Fibonacci numbers, and so is 377.

The same rule flattened onto a disc. Move the angle off 137.5° and the spirals collapse into spokes.

Finding the nearest point for each pixel would mean checking all 377. But the points are laid down in height order, so the index of the nearest one can be estimated straight from the pixel's height, i ≈ (1 − z)·N/2. Each pixel then only checks the 73 points around that index.

What it costs

PartRunsWork
Atlas raymarchOnce per sizeUp to 160 steps a pixel
Grain passEvery frame, while on screenOne texture read, the sphere, some noise
SandEvery frame, while the tab is openA few thousand grains, one buffer write
Demos hereOnly while scrolled into viewEach stops when it leaves the screen

Atlas is drawn at the screen's own pixel density, up to 2×. An earlier version drew it at CSS size and let the browser scale it up, and on a retina screen that read as blur. Drawing every device pixel made the grain noticeably sharper.

If something here is broken on your phone, I would like to know: vaidik.vadhavana@gmail.com.

← All writing