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.
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.
A bass that never stops, a chord held over it, and notes arriving
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.
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.
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 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.
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 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'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.
The whole row is the target, 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.
Play the music from its island at the top of the home page and watch the stave behind the navbar.