DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our footer is a drawn landscape at 10 per cent opacity, and it is 139 KB of every page's HTML

The footer on Nakodo is a landscape. Two trees, four bushes, thirteen tufts of grass and a
kingfisher sitting in the one on the right, faded back to 10 per cent so it reads as paper rather than as a
picture. It is on all 76 public pages, which makes it the single biggest thing in the HTML of the small ones, and
I had never measured it.

Here is the measurement, on the glossary page, which is a page of plain text:

$ curl -s https://nakodo.app/glossary | wc -c
  526915
Enter fullscreen mode Exit fullscreen mode

Of those 526,915 bytes, the <footer> element is 139,325, holding 23 <svg> elements and 302 <path> elements
whose d attributes alone are 109,041 bytes. Pulling the footer's SVGs apart by size:

Drawing Count Paths each Bytes each
Tree (left, from md) 1 102 38,231
Kingfisher in a tree (right) 1 74 22,949
Tuft of grass (from lg) 13 5 ~4,428
Bush (from lg) 4 12 ~1,980
Tree trunk continuation 1 1 336
Wordmark bird and the YouTube logo 2 1 and 9 574 and 3,699

Two things in that table are the wrong way round from what I expected.

A blade of grass costs more than a bush

A tuft of grass is five paths and 4.4 KB. A bush is twelve paths and 2.0 KB. The tufts are the smallest thing on
the screen, 35 units square, and thirteen of them are 57 KB of the page, half again what the big tree costs.

The reason is in how each is drawn. A bush is mounds: a half ellipse whose edge is broken into scallops, each
scallop one A arc command, so a whole mound is a dozen short commands.

// A mound of foliage: a half ellipse with its edge broken into n scallops that
// bulge outward. Its foot runs off the bottom edge.
function mound(cx: number, base: number, rx: number, ry: number, n: number): string {
  const at = (i: number) => onDome(cx, base, rx, ry, n, i);
  let d = `M${fmt([cx - rx, base + 4])} L${fmt(at(0))}`;
  for (let i = 1; i <= n; i++) d += scallop(at(i - 1), at(i));
  return `${d} L${fmt([cx + rx, base + 4])} Z`;
}
Enter fullscreen mode Exit fullscreen mode

A blade of grass is a limb, and a limb is the expensive primitive in the drawing library: a cubic Bezier sampled
at 17 points, each point pushed out along the curve's normal by half the width at that point, the two sides
joined with a rounded tip.

export function limb(p0: Point, p1: Point, p2: Point, p3: Point, w0: number, w1: number): string {
  const steps = 16;
  // ... sample the curve, offset each sample by the tapering half-width ...
  right.reverse();
  return `M${fmt(left[0])}${smooth(left)} A${w1 / 2} ${w1 / 2} 0 0 0 ${fmt(right[0])}${smooth(right)} Z`;
}
Enter fullscreen mode Exit fullscreen mode

That is 34 points and 32 quadratic commands for one tapering shape, because the taper has to be a filled outline
rather than a stroke: a stroke of varying width is not a thing SVG has. Five blades, each about 870 bytes, and
the grass is the most detailed geometry in a drawing nobody is meant to look at.

It is cheap where it matters, mind. The five blade strings are computed once at module scope:

const BLADES = [
  limb([16, 38], [12, 30], [7, 25], [1, 22], 2.4, 0),
  limb([17, 38], [15, 28], [12, 18], [8, 10], 2.4, 0),
  // ...three more
];
Enter fullscreen mode Exit fullscreen mode

So all thirteen tufts are the same five strings repeated, which is why the number that reaches a browser is not
the number above:

$ curl -s -H 'Accept-Encoding: br' -o /dev/null -w '%{size_download}\n' https://nakodo.app/glossary
   47715
Enter fullscreen mode Exit fullscreen mode

47.7 KB over the wire for a 527 KB document. Thirteen identical tufts are nearly free once compressed, which is
the honest end of this story and also the reason nobody noticed.

The trunk is a 51-unit slice of its own tree

The left tree hangs from the top of the footer so that its crown keeps its place around the wordmark however tall
the footer gets. Which leaves a gap below it, because the footer's height depends on how many link columns wrap.
The fix is a second element that is one path: the two edges of the trunk, continued.

$ curl -s https://nakodo.app/glossary | grep -o 'viewBox="224.3 0 [^"]*"'
viewBox="224.3 0 51.39999999999998 10"
Enter fullscreen mode Exit fullscreen mode

That is a window onto the same coordinate space as the tree above it, 51.4 units wide where the trunk is, 10 tall,
with preserveAspectRatio="none" so it stretches to whatever height remains. The 51.39999999999998 is floating
point arithmetic on the trunk's own left and right edges, shipped as is, and a toFixed would be the kind of
tidying that makes a value look authored when it is derived.

The comment in the file is there for the next person, because this took a while:

// The svg is stretched by the box around it, since an svg given both a top and
// a bottom takes its height from its aspect ratio.
Enter fullscreen mode Exit fullscreen mode

An <svg> with position: absolute; top: 0; bottom: 0 does not fill the gap: it computes its height from the
viewBox's aspect ratio and leaves the rest. It needs a plain <div> doing the stretching and the svg filling the
div.

One opacity layer, not twenty

Every drawing is inside a single wrapper:

<div className="pointer-events-none absolute inset-0 opacity-10">
Enter fullscreen mode Exit fullscreen mode

Fading each drawing to 10 per cent separately would look different, and worse: where a bush stands in front of a
trunk you would see the trunk through it, because two 10 per cent shapes stacked are darker than one. Fading the
composite means the bush hides what is behind it exactly as it would at full strength. The whole layer is also
pointer-events-none, so none of it can swallow a click on a link, and every drawing carries aria-hidden from
the shared Scenery wrapper: 22 of the 23 <svg> elements in the footer have it, and the 23rd is nested inside
one that does.

A phone paints one of the twenty drawings

The grass is in a hidden lg:block div, the bushes in a hidden ... lg:flex one, and the left tree and its
trunk in a hidden md:block one. On a phone the footer paints exactly one drawing, the kingfisher in its tree,
and the browser has still parsed all twenty, because a Tailwind hidden class is display: none and not a
reason for the server not to render it.

That is 104 KB of markup a phone will never show, out of the footer's 139 KB, in a 527 KB document that is, in
fairness, 47.7 KB over the wire. I am not going to fix it by rendering different footers for different widths, because that needs the
viewport at render time and these pages are static. The option I would take is making the grass a single repeating
background with one tuft in it, which is one drawing instead of thirteen and would let the CSS decide how many
are visible.

The other half of the footer is a sitemap

The drawing is the part people notice. The reason the footer exists is this:

$ curl -s https://nakodo.app/glossary | sed -n 's/.*<footer//p' | grep -o '<a ' | wc -l
      36
Enter fullscreen mode Exit fullscreen mode

36 links, in five columns, and not one of their labels is typed in the layout. They come out of the content
registry:

const guide = (slug: string) => {
  const e = findEntry("guide", slug);
  if (!e) throw new Error(`Footer links to a missing guide: ${slug}`);
  return { href: e.path, label: e.page.label };
};

// Every content page is two clicks from any public page: the footer links each
// family's hub and its main pages. Labels come from the content itself.
Enter fullscreen mode Exit fullscreen mode

Two properties fall out of that. A renamed page renames its own footer link, because the label is the page's
label field. And a deleted page fails the build: guide() throws during rendering rather than shipping a footer
link to a 404. The footer also names every family hub, so with 63 content pages behind five hubs, no page is more
than two clicks from any other page on the site.

And a year that was decided at build time

const thisYear = () => new Date().getFullYear();
Enter fullscreen mode Exit fullscreen mode

That reads like it keeps itself right. It does not, because the page it sits in is prerendered:

$ curl -sI https://nakodo.app/glossary | grep -iE 'x-nextjs-prerender|x-vercel-cache|^age'
age: 569952
x-nextjs-prerender: 1
x-vercel-cache: HIT
Enter fullscreen mode Exit fullscreen mode

That HTML was generated at build and the copy the CDN handed me is six and a half days old. new Date() ran on a
build machine, so the © 2026 in the footer is the year of the last deploy, and on 1 January every page that has
not been rebuilt will be a year behind. The fix is not a dynamic page for one number: it is to write the year the
site started and let the copyright line say that, or to accept that a deploy happens often enough and write the
comment that says so. What I will not keep is a function whose name promises something the rendering model does
not deliver.

One more number, for scale. Server-rendered SVG passed through a component tree is serialised into the page's
flight data as well as its markup, so the path data is in the document twice: 222,088 bytes of d attributes
across the whole page, 42 per cent of those 527 KB, for a drawing at 10 per cent opacity that most visitors
scroll past.

Go and scroll to the bottom of any page: the glossary has the full landscape at a
wide window, a guide has the same one, and a phone
gets the bird on its own. The layout is 205 lines, the footer it builds is 139 KB, and 127 KB of that is plants.

Top comments (0)