← All notes

What a scramble is made of

Letters as boxes, symbols picked by width, and all the small numbers that make a title feel alive instead of broken.

Every title on this site comes apart when you touch it. A few letters turn into colored symbols around your pointer, then settle back. It’s the first thing people ask me about, and the answer they expect is “it swaps letters for random characters,” which is what it does. It’s also what every broken version of this effect does.

The difference is entirely in the details, so here’s the same title four times, one idea at a time. Start on the left, look at the end of the word, and work your way across.

somebody did.

The end of the word jumps up to 0.0 px in one frame

“Any symbol” is what you’d write in five minutes, and the whole line shudders, because a W isn’t as wide as an i. “The closest one” helps, and it’s still twitchy. “And a glide” is where it stops looking like a bug. “And deadlines” is where it starts looking alive. The rest of this note is about what’s behind each of those.

One box per letter

To change one letter you need to be able to reach it, so each letter gets its own element. Most scramble scripts do that after the page has loaded, by cutting the text up. That means the server sends one thing and the browser ends up with another. Here the letters are rendered from the start, so what the server sends is exactly what React picks up.

src/components/scramble.tsx
<span className="sr">{children}</span>
{children.split(/(\s+)/).map((part, i) => {
  if (!part.trim()) return part; // a space stays a real space
  return (
    <span className="w" aria-hidden="true" key={i}>
      {[...part].map((ch, j) => (
        <span className="c" key={j}>
          <span className="g">{ch}</span>
        </span>
      ))}
    </span>
  );
})}

There are three levels, and each one has a single job. A word (.w) can’t break across lines. A box (.c) is what slides sideways. A glyph (.g) is what hops up and down, so the two movements never interfere. And since a screen reader would spell all of that out letter by letter, the boxes are hidden from it and it gets one clean copy of the real text instead. So does anyone who copies the title: only one version gets selected.

What you lose by doing that

Splitting text into boxes quietly breaks something: kerning. Fonts tuck certain pairs of letters closer together, like the A and the W, but the browser only does that inside one piece of text. Separate boxes know nothing about each other. Titles get slightly looser, and you can’t quite say why. The code has a comment calling it “Int erfaces.”

AWAY To

The fix is nice, because the browser already knows the answer. A canvas does kern when it measures text. So I measure the two letters together, then each one alone, and the difference is the kerning for that pair. I put it back as a margin, in em, so it follows the font size.

src/lib/scramble-engine.ts
box.style.marginRight = `${(advance(font, a + b) - advance(font, a) - advance(font, b)) / 100}em`;

Picking the symbol

There are eleven symbols, and they’re not picked at random. Before anything happens, each letter measures all of them and sorts them by how close they are to its own width. Then each time it needs one, it draws from the five nearest. A wide m gets wide symbols, and a thin i gets thin ones.

m
Nearest
5 of 11
Wild
20%

Then comes the part I’d never have guessed: one time in five, any symbol will do. That’s the orange flash in the demo. Set “Wild” to zero and the title barely moves at all, which sounds good and looks dead. Set it to a hundred and it never stops shaking. A little bit of the wrong thing is what makes the right thing look deliberate.

src/lib/scramble-engine.ts
nearest: 5, // pick among the N symbols closest in width
wild: 0.2, // …except 1 time in 5, where any will do

const from = Math.random() < SCR.wild ? L.pool : L.pool.slice(0, SCR.nearest);
const ok = from.filter((g) => !avoid.includes(g.ch));

That last line avoids two things: the symbol this letter is already showing, and the one its neighbor just took. Colors get the same rule: two neighbors never get the same one. You’d never notice that as a rule, you’d only notice the day it stopped being true.

The glide

Even with the closest symbol, a box still changes width, and everything after it has to move. Left alone, that’s a jump. So nothing is ever allowed to just arrive: when a box’s real position changes, it’s immediately pushed back to where it was, by exactly that much. Then the gap shrinks by a quarter every frame.

src/lib/scramble-engine.ts
for (const L of letters) L.x = L.box.offsetLeft; // all reads first…
for (const L of letters) {
  // …then all writes (avoids recomputing layout for every letter)
  if (L.lastX !== null && L.x !== L.lastX) L.shift -= L.x - L.lastX;
  L.lastX = L.x;
  L.shift *= 1 - SCR.drift;
  L.box.style.transform = `translateX(${L.shift.toFixed(2)}px)`;
}

This is an old technique called FLIP, and it depends on one small convenient fact: offsetLeft ignores transforms. It tells you where the box really is, not where I’m currently drawing it, so there’s no need to undo anything before measuring. Boxes that move again in the middle of a glide just add to the gap. No scaling, no stretched letters, no spring to tune, and neighbors make room like people on a bench.

It’s also why the loop can sleep. Six quiet frames in a row and it stops running at all, so a title nobody’s touching costs nothing.

Nothing happens at once

All of that changes where things go, and it still looks mechanical, because every letter changes on the same frame. The fix is the smallest idea in the whole engine: no change applies right away. Each letter gets a random deadline, up to 70 milliseconds on the way in and up to 150 on the way back. The dots below are the real deadlines of the last pass.

somebody did.
In
70 ms
Out
150 ms
Hop
0.09 em

“A switch” is a title that reacts. “This site” is a title that notices you. Nobody would call 70 milliseconds a delay, and yet you can see the difference from across a room. Coming back takes longer than going in, for the same reason a light goes on quickly and a room takes a moment to get dark.

The hop is the last touch: each new symbol starts a little low and rises into place. Push it up and it turns into a cartoon, take it away and the letters just blink. It’s under a tenth of a letter’s height, and its size is random too, so no two are the same.

Where the pointer counts

If it woke up whenever your pointer came near, every title would flinch as you crossed the page. If it only reacted right under the pointer, you’d get one letter at a time, which feels stingy. So there are two zones, and they’re different shapes and sizes.

somebody did.

Asleep: the inner ring isn’t on a letter

Wake
0.6 em
Reach
1.7 em
Redraw
16 px

The inner ring is small: to wake a title up, you really have to be on a letter. But once it’s awake, the outer ring touches much more. So it stays out of the way when you’re passing through, and it’s generous once you stop. Both are measured in em, not pixels, so the big title on the home page and a small label behave the same way without anyone adjusting anything.

src/lib/scramble-engine.ts
wake: { x: 0.6, y: 0.55 }, // you have to be really on a letter to trigger it…
reach: { x: 1.7, y: 1.3 }, // …but the effect then touches a wider area

if (!probes.some((p) => inside(p, SCR.wake))) return releaseAll();

Moving the pointer doesn’t redraw the symbols either, or they’d flicker at the speed of your hand. They only change every 16 pixels you travel. Slow down and they calm down, and stop, and they hold still. Drag “Redraw” to 2 and you’ll see why that number exists.

The page, coming in

The same pieces also run once without you, when a page loads. Every character arrives in reading order as a flickering symbol, fading in from nothing, and settles on its real letter. On the home page, the big title stays invisible until that wave reaches it, so the scramble really does uncover it.

Most interfaces feel like nobody made them. I’m leouiux, a design engineer from Fribourg, and mine should feel like somebody did.
Curve
1.5
Length
1000 ms

The spacing isn’t even. A curve of 1.5 means the first letters arrive close together and the gaps get longer down the page, so it starts fast and lands gently. It’s always the same one second in total, however much text there is. And it stops at the bottom of the first screen: what’s below would spend the time unseen, so it’s simply there. Go on, press “Replay the whole page.”

src/lib/scramble-engine.ts
const start = t0 + total * (i / Math.max(order.length - 1, 1)) ** curve;
return { L, start, end: start + hold + rand(0, holdJitter), rolled: 0 };

Unlike a title under your pointer, the boxes hold their width during this, which means a paragraph can’t rewrap while it’s loading. The page itself is hidden by an attribute set in the head of the document, before anything has painted, so there’s no flash of finished text before the animation.

A word must never hop

Here’s the bug that took me the longest to see, because it only shows up at certain widths. A wider symbol can push a word over the edge of the line, and then the word jumps down. Then the symbol goes away and it jumps back up. On a big title, that’s the whole layout twitching.

Most interfaces feel like nobody made them.

Words changed line 0 times

The answer feels like cheating. I let the browser wrap the title normally, I note where each line starts, and then I pin those breaks with invisible line breaks and forbid any other wrapping. Now symbols are free to widen a line, and no word can ever leave it. It’s done again whenever the window’s width changes.

src/lib/scramble-engine.ts
const bottoms = atoms.map((atom) => atom.getBoundingClientRect().bottom); // all reads first…
atoms.forEach((atom, i) => {
  if (i && bottoms[i] - bottoms[i - 1] > fs / 2) {
    const br = document.createElement("br");
    br.dataset.lock = "";
    atom.before(br);
  }
});
block.style.whiteSpace = "nowrap";

The hand-drawn marks on the home page have the same problem: a circle around a word would stretch and shake as its letters changed. So once the fonts are in, each mark’s width is frozen, in em so it still follows the title’s size.

It plays drums too

Once letters could be told what to do, it didn’t take long for them to start taking orders from something other than the pointer. While the song plays, the titles run on a different set of rules: no random deadlines, since a drum hit has to land exactly on time, and big or small gestures depending on the sound. That’s a whole other note, “The cat has good taste,” with a kick you can play yourself.

The cursor listens too. While you’re in a scramble it switches to the same symbols, in orange, and it reads one flag from the engine to know when. It’s two lines, and it’s why the pointer and the title feel like one thing.

Where it stops

It’s only on titles and labels. A long article shouldn’t be thousands of animated spans, so body text stays plain text, which is why you’ve been able to read all this. On a phone there’s no pointer, so titles only do the load animation. If you’ve asked your system for less motion, the page takes you at your word, and the letters are just letters, with their kerning restored.

It started as one more animation test of mine, like plenty of others. Most of the numbers on this page came from there, and they’ve barely moved. What changed is everything around them: it’s written for React now, the server sends the letters, and the kerning, the locked lines and the song are all new.