Cover Art

In an attempt to avoid stock photography, a menagerie of AI outputs, and questionable Canva concoctions, this website uses a little coding magic so that each post effectively grows its own cover — the trees you see when browsing the articles here.

This post is about how that’s done.

Quickly, credit where due: while thinking about how to avoid ill-devised blog covers, I dug through the work of Amy Goodchild, Anders Hoff, barbe_generative_diary, Jessica Rosenkrantz and Jesse Louis-Rosenberg. Most importantly, I’m indebted to Danielle Navarro and her flametree package.


One function, three rules

Every tree on this site is grown from a single function called, fittingly, grow . Feed the function a post title, it hands back a tree.

(If you don’t understand code, don’t worry. You can skim the code blocks below if you’d like. At any rate explanations are written to be high-level and friendly to a non-technical reader.)

function grow(title) {
  const rand = mulberry32(hash(title));
  const segs = [];
  const blooms = [];
  const lean = (rand() - 0.5) * 0.5;
  const stack = [
    { x: 0.5, y: 1.0, a: -Math.PI / 2, len: 0.20, depth: 0, w: 2.4 },
  ];
  // ...pop a branch, grow it, decide whether to keep going.
}

The first line of the function, const rand = mulberry32(hash(title)) , is essentially the whole trick.

hash squashes the (title) of a post into a single numbera single numberhash produces a 32-bit integer (2³²; a number space of 0 to ~4.3 billion). A good hash flings similar inputs to wildly separated points in that space. This is why "Wishlist" and "Wishlizt" land on totally different seeds. If the number space were small (say 0–100), many titles would collide onto the same number — same number, same tree — and so tiny edits might not separate cleanly. . For example, “Workflow Wishlist” is converted into something like 2,476,394,107. Change one letter of that title, and the number generated would be completely different.

mulberry32 takes the number generated by hash and returns a generator — a little function we store as rand . Each time we call rand() , it hands back the next value in a stream of random-looking numbers between 0 and 1. These values determine the count, angle, length, and wobble of every branch and the scatter of every blossom. The “random-looking” component matters: mulberry32 is a pseudo-random number generator, so the same seed always produces the same stream of values, in the same order. Same title in, and the same values will be ‘rolled’ out, every time. So “Workflow Wishlist” always grows the same tree.

Put another way: hash decides which shuffled deck you’re dealt; mulberry32 deals the cards one at a time. And all in all — keeping with our tree theme — the post title is effectively the tree’s seed.

From there, our tree starts at the bottom and grows upwards, with every branch facing three questions:

  1. How many child branches should grow here?
  2. At what angle and length should each branch grow?
  3. Should another branch sprout, or is this limb of the tree done?

The answers to these questions are determined by the while loop:

while (stack.length) {
  const n = stack.pop();
  const x2 = n.x + Math.cos(n.a) * n.len;
  const y2 = n.y + Math.sin(n.a) * n.len;
  segs.push({ x1: n.x, y1: n.y, x2, y2, w: n.w });

  if (n.depth >= maxDepth || n.len < 0.014) {
    const k = 1 + Math.floor(rand() * 4);
    for (let i = 0; i < k; i++) {
      blooms.push({
        x: x2 + (rand() - 0.5) * 0.022,
        y: y2 + (rand() - 0.5) * 0.022,
        r: 0.004 + rand() * 0.005,
      });
    }
    continue;
  }

  const kids = rand() < 0.16 ? 1 : rand() < 0.86 ? 2 : 3;
  for (let c = 0; c < kids; c++) {
    const off = c - (kids - 1) / 2;
    const na = n.a + off * (0.32 + rand() * 0.28)
                   + lean * 0.4
                   + (rand() - 0.5) * 0.22;
    stack.push({
      x: x2, y: y2,
      a: na,
      len: n.len * (0.72 + rand() * 0.12),
      depth: n.depth + 1,
      w: Math.max(0.4, n.w * 0.7),
    });
  }
}

The loop is an iterative tree-builder. stack is the pile of branches waiting to be drawn. With each pass, the loop takes a branch off the top of the pile (pop ), works out where that branch ends, records it, then decides — if the branch has gone deep/short enough, cap it with a few blossoms and move on; otherwise, spawn one to three child branches and drop them back on the pile (push ) to be grown later. The loop repeats until the pile is empty and the tree is done.

To what degree a branch splits, how fast it thins, how wide it fans, whether it finally terminates in blossoms — these are determined probabilistically. rand() is 0–1; under 0.16 → one child branch, under 0.86 → two, otherwise three. Chance-wise, there’s a 16% chance of one branch, 70% of two, 14% chance of three.

What stops the tree growing forever is two limits: a maximum depth and a minimum length (maxDepth and len < 0.014 ). Once a branch hits either, it stops splitting and ends in blossoms instead. And because every child is shorter and one level deeper than its parent, every branch eventually trips one of those limits.


Two themes, one geometry

The site has a theme toggle. Minimal is the default — black and white, restrained, mostly type. Romantic is the alternate mode — steel blue, dramatic, a world after dusk…a little bit emo.

I wanted the same tree in both modes, but recolored accordingly.

The trick here: the tree’s base growth process never names a color. Instead, each tree points at a named variable, like --cover-ink , which functions as a kind of slot. Each theme decides what color fills that slot.

<svg class="tree-cover__svg" viewBox="0 0 100 100">
  <g class="tree-cover__branches">
    <line x1="50" y1="100" x2="50" y2="80" stroke-width="1.32" />
    ...
  </g>
  <g class="tree-cover__blooms">
    <circle cx="..." cy="..." r="..." class="tree-cover__bloom" />
    ...
  </g>
</svg>

<style>
  .tree-cover__branches { stroke: var(--cover-ink); fill: none; }
  .tree-cover__bloom    { fill: var(--cover-ink); }

  :root[data-theme="romantic"] .tree-cover__bloom {
    fill: var(--cover-bloom);
  }
  :root[data-theme="romantic"] .tree-cover__bloom--accent {
    fill: var(--accent);
  }
</style>

The branches are line segments, the blossoms are circles, and both carry var(--cover-ink) , a reference to that named slot. The only theme-specific lines are the last ones: when the page is in romantic mode, fill the slots with different values.

So the same tree drawing lives in both worlds — no re-render, no re-fetch. (If you’ve never seen var(--cover-ink) : it just means “whatever color is in the slot named cover-ink” — set it once, read it everywhere.)

In romantic mode there’s also a backdrop: a soft glow, ditheredditheredDithering fakes shades you don't actually have by scattering dots of the colors you do have. Step back and your eye blends them into a tone. It's how newspapers and early low-color screens produced gradients. Here, a soft glow is built from just three flat steel-blue tones arranged in an ordered (4×4 Bayer) pattern. into three flat steel-blue tones, sitting behind the tree. The tree itself is never dithered — the line art would turn into broken noise if dithered (I tested this). Dithering seems best for soft, tonal things, so only the glow gets it. And the crisp tree sits in front, untouched.


A touch of content influence

The title fixes the tree’s gesture, and that’s deliberate — I want a cover to be stable across edits.

But I also want each cover to be a thumbprint, even if only slightly, of the given post’s content. A long essay should look a bit fuller than a short note; a post with many sections should have a few more main branches; a heavily-tagged post, a few more blossoms.

So there’s a small dial for this:

const ci = 0.4;
const lengthScore = normalize(wordCount, 200, 2000); // → -1..+1
const maxDepth = clamp(
  9 + lengthScore * ci * (10 - 9),
  8, 10,
);

// (Same pattern — normalize → multiply → clamp — applies to the number
//  of main branches (2..4) and the bloom density (0.6× to 1.4×).)

ci — for content influence — is a knob from 0 to 1: 0 means the post’s body gets no say in how the tree grows, 1 gives it the most say I’ll allow. Think of it as a percentage of influence. I keep it at 0.4, so length matters, but only a little.

Here’s what it acts on. Every tree has a depth — how many times a branch can keep splitting before it stops, which is really just how full the tree ends up. The default is 9. A post’s length nudges that number up or down just a touch: a long essay grows a slightly fuller tree, a short note a slightly barer one. The nudge is capped so the depth never leaves the 8-to-10 range. That’s the whole mechanism: a fixed starting point, plus a gentle, bounded nudge from length.

The thing to hold onto here is that none of this touches the tree’s shape. Which way it leans, the angle of each branch, its whole gesture — that’s set the moment a post is titled, and the body can’t move it. Length can make a tree fuller or barer; it can’t change which tree it is.

That’s important. I wanted a post’s tree to be recognizably its own — so if you wander back a week later, you know it on sight. If the body seeded the shape too, every little edit would quietly redraw the whole thing, and the cover would never settle into an identity. So the title gets the shape; the body just gets to breathe on it.


What’s next, maybe

Some things I’m considering for the future:

  • A tree-aware art system that responds to the meaning of content, i.e. essays grow oaks, notes grow willows. Word count and section count are crude proxies.
  • Hover animations, particle effects, seasonal variations. Tree animations on load.
  • Space colonization, whereby attractor points “pull” branches toward them, producing more organic shapes.