← Case study

Detail 11 · Atmosphere

The details nobody asked for

Strings plucked by the music, grain that doesn't blink, the scroll ruler with its page index, and click targets as big as their rows. 9 minutes, 4 demos.

Try it: pluck the strings

01 · The idea

Things nobody would miss

No brief asks for strings behind the navbar that ring with the music, or for film grain, or for a scrollbar drawn with a ruler, or for a link that’s bigger than its words. Take any of them away and the site still works. But together they’re why it feels made by hand rather than assembled, and each one costs almost nothing while it’s quiet.

They also have to stay quiet. They’re there all the time, so the rule for each was the same: it should be felt more than seen, and it should never cost the page anything you’d notice.

02 · The strings

Strings plucked by the music

Behind the navbar are five faint pencil lines, a stave of sheet music. When the music plays they become guitar strings: each one listens to its own band of the sound, high notes on the top string and low ones on the bottom, and rings when a note arrives in it.

Below are the same five strings, drawn brighter than the site’s. They play sample notes over a bass that never stops; switch to the site’s music to hear the real thing pluck them. The meter beside each string shows its band’s level, the tick its recent average, and in ember the part that plucks it. Switch off “Only new notes” to see what happens without it.

Five strings, five bands

A bass that never stops, a chord held over it, and notes arriving

Listen to

The music plays very quietly, so it’s listened to before it’s turned down: the audio runs through an analyser first and only then through the volume, and the analyser hears the full signal.

Listen first, then turn it down
function createAudioGraph(audio) {
  const AudioContextClass = window.AudioContext || window.webkitAudioContext;
  const ctx = new AudioContextClass();
  const source = ctx.createMediaElementSource(audio);
  const analyser = ctx.createAnalyser();
  analyser.fftSize = 512;
  analyser.smoothingTimeConstant = 0.6;
  const gain = ctx.createGain();
  gain.gain.value = 0;
  source.connect(analyser).connect(gain).connect(ctx.destination);

A string isn’t plucked by how loud its band is, but by how far the band rises above its own recent average. Without that, the bottom string would never stop moving: bass is always the loudest part of a piano track. Measured against itself, a held chord settles to nothing and only new notes pluck.

Plucked by what's new, not what's loud
export function pluckStrength(string, spectrum, [from, to]) {
  let sum = 0;
  for (let bin = from; bin < to; bin++) sum += spectrum[bin];
  const energy = sum / ((to - from) * 255);
  string.average += (energy - string.average) * 0.03;
  string.peak = Math.max(energy, string.peak * 0.995, 0.05);
  return Math.min(Math.max((energy - string.average) / Math.max(string.peak - string.average, 0.02), 0), 1);
}

Then each string rings out on its own: its swing decays a little every frame, higher strings shimmer faster, like thinner strings, and the shape is a standing wave, still at both ends. When nothing rings and nothing has been resized, the strings aren’t redrawn at all.

03 · The grain

Grain that doesn't blink

A faint film grain lies over every page. The first version was a 3.1 MB noise image on a layer three screens wide, slid between two spots in percent, ten little steps at a time. It was heavy, and on a laptop at 125% display scaling it blinked.

The blink came from where the layer landed. A percent of a layer that size is rarely a whole number of pixels, and once the display scales it, almost never. When an image lands between pixels, the screen blends each pixel with its neighbours, and blended grain is softer. Some frames came out crisp and some soft, ten times a second.

The same grain, moved two ways
Before: slid in percent
x 0y 0on the grid
100% crisp
After: whole 20px jumps
x 0y 0on the grid
100% crisp
Display scaling

The grain is now a 600-byte SVG inside the CSS, rasterized once and only ever moved. It jumps rather than slides, held still between jumps, and every jump is a whole multiple of 20 pixels: at 125%, 150% or 175% scaling that’s still a whole number of screen pixels, so every frame is the same grain, just somewhere else.

Held still, and moved only by whole pixels
  background-size: 200px;
  /* Rasterize the grain once, then only move it */
  will-change: transform;
  /* steps(1) holds each keyframe, so the grain jumps instead of sliding */
  animation: grain 1s steps(1) infinite;
}

/*
  Offsets are whole multiples of 20px, so the grain lands on exact device
  pixels even at 125%/150%/175% display scaling. Fractional offsets resample
  the noise differently each frame, which reads as blinking.
*/
@keyframes grain {
  0% {
    transform: translate(0, 0);
  }
  10% {
    transform: translate(-100px, -60px);
  }
  20% {
    transform: translate(60px, -100px);
  }

04 · The ruler

A scrollbar that knows the page

The browser’s scrollbar is replaced by a ruler drawn in pencil, its read part hatched in ember. On a page with sections, it also carries the page’s index. At rest it shows only the section you’re reading; point at the ruler and the page fades back while the whole index comes out, each label joined by a pencil line to its spot on the ruler.

A ruler that knows the page
Detail 11The details nobody asked for
The idea
Watch it
How it works
Decisions
Takeaways

A section’s spot is where the marker will be when that section reaches the reading line, a third of the way down the screen. So the marker crosses a section’s line just as its label lights up. Sections close together would put their labels on top of each other, so the labels are pushed apart and their lines bend back to the true spots. Switch “Keep labels apart” off to see the problem.

Each label at its spot, nudged apart where they crowd
/** Each label's height on the ruler: at its section's spot, nudged apart where sections are close. */
export function placeLabels(sections, height) {
  const ys = [];
  sections.forEach((s, i) => ys.push(Math.max(s.at * height, i ? ys[i - 1] + MIN_GAP : -Infinity)));
  // Pushed past the bottom? Slide the tail back up
  for (let i = ys.length - 1; i >= 0; i--) {
    ys[i] = Math.min(ys[i], i === ys.length - 1 ? height : ys[i + 1] - MIN_GAP);
  }
  return sections.map((s, i) => ({ ...s, spot: s.at * height, y: ys[i] }));
}

Any page can have an index: it marks its sections with data-page-section, and the ruler finds them. Hovering is a mouse’s, so on a touchscreen a tap on the ruler opens the index and a drag still scrolls. From the keyboard, a hidden “Jump to a section” stop opens it, and Tab walks the labels.

05 · The hit area

Click the row, not the words

The list of details beside this page started with each detail’s title as a link. Pointing anywhere along a row lit its tick on the ruler, so the whole row looked like a target, but only the words were. Click just past the end of a short title, or on the ruler beside it, and nothing happened.

Now the link fills the row: the full width of the list, and back across the ruler to its tick. The pencil box around the open detail still fits the words, so nothing looks different. It just stops missing.

Where a click lands

The whole row is the target, back across the ruler

One target per row, back across the ruler
  data-ruler-row="inked"
  data-key={lesson.slug}
  data-slug={lesson.slug}
  {...rowEvents(lesson.slug, lesson)}
  className="-ml-8"
>
  <TransitionLink
    href={detailHref(lesson.slug)}
    aria-current={isCurrent ? "page" : undefined}
    {...focusEvents(lesson.slug, lesson)}
    data-wf-text="body"
    className={`relative block py-1.5 pl-8 font-body text-[0.82rem] leading-snug transition-colors duration-300 ${
      isCurrent ? "text-foreground" : "text-foreground/70 hover:text-foreground"
    }`}
  >
    <span data-row-title>{lesson.title}</span>
  </TransitionLink>

It’s the kind of thing no one reports. A click that does nothing doesn’t feel like a bug; it feels like the site being slightly hard to use, and you can’t say why.

06 · Decisions

Why it’s built this way

Felt, not seen

The strings are faint, the grain is at 15%, the ruler is low-contrast pencil. Each one is there all the time, so none of them can ask for attention.

Relative, not loud

Each string listens against its own average, so a quiet passage still plucks and a loud one doesn’t drown everything.

Fix the blink at the root

Not lower opacity or a slower drift: whole-pixel jumps, so no frame is ever resampled.

The scrollbar is the index

Where you are and where you can go, in one place you already look. No extra table of contents on the page.

07 · Takeaways

What to keep

  • Ambient details have to be quiet and nearly free: felt more than seen, and idle when nothing changes.
  • React to change, not level: measure a signal against its own recent average.
  • Move rasterized layers by whole device pixels, and hold them still between moves, or they’ll shimmer.
  • If something reacts to the pointer like a target, make all of it the target.
Listen to the real strings→

Play the music from its island at the top of the home page and watch the stave behind the navbar.