sketchyicons

The wobble is random, and it is the same every time

The wobble in these icons comes from a random sequence. That sequence is seeded from the icon's name, so it is the same sequence on your machine, on mine, and on the CI runner. Two generator runs write byte identical files, and a test fails if they ever stop.

Why a clock would ruin it

Seed a generator from Math.random() and every run redraws all 1,756 glyphs a little differently. Nothing breaks. The icons still look fine. But an upstream bump, a dependency install, a rebuild on a different machine, any of those now produces a diff touching every file in the package.

At that point nobody reviews the diff, because there is nothing in it to review. And a diff nobody reads is where a real regression goes to hide.

The seed is the name

An FNV-1a hash of the icon name feeds an xorshift generator. Both fit in a few lines, both are deterministic, and neither reaches for anything outside the string it was handed.

the whole random source
let seed = 2166136261;
for (const character of name) {
  seed ^= character.charCodeAt(0);
  seed = Math.imul(seed, 16777619);
}

return () => {
  seed ^= seed << 13;
  seed ^= seed >>> 17;
  seed ^= seed << 5;
  return ((seed >>> 0) % 100000) / 100000;
};

anchor always draws the same anchor. Not a similar one, the same one, down to the two decimal places every coordinate is rounded to.

What it costs

There is a real trade here, and it caught me out early. Because the drawing is decided by the name, there is no way to redraw one icon. Run the generator again and you get the same glyph back. If a specific icon lands badly, you cannot shake it until it looks right.

So there is an overrides file. An icon a human looked at and rejected is listed there with a seed offset or a different amplitude, and a note explaining why, which the build refuses to run without. It is the one place in the pipeline where taste is written down instead of computed, and keeping it small is the point.

packages/data/overrides.json
{
  "smile-plus": { "seed": 2, "why": "the eyes landed on top of each other" },
  "crown": { "drift": 0.6, "bow": 1.1, "why": "too calm for a crown" }
}

The test that keeps it honest

A property nobody checks stops being a property. The suite runs the generator twice over the catalogue and compares the output byte for byte, so the day someone reaches for a convenient Date.now() the build says so.

The geometry is also committed rather than built on install. An upstream Lucide bump shows up as changed path strings in one package and nowhere else, which is a diff worth reading.

A note on the alternative

You can also do this at runtime, roughening the paths in the browser as the icon renders. That gets you a roughness dial the consumer can turn, which is genuinely nice, at the cost of a render step per icon and a geometry that no longer exists as a file you can read.

Build time was the choice here because the diff argument above mattered more to me than the dial. If you want the dial, the comparison post covers what else changes with it.

Keep reading