DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Six hand-written utilities in 154 KB of CSS, and the one rule that works because it is in no layer

Nakodo is drawn to look like a field guide: paper, cyanotype plates, line drawings, mono
numbers. All of that is Tailwind 4 classes in the markup, except for six things that no combination of utilities
can say. Those six are @utility rules in src/app/globals.css, which is 865 lines, and I have just measured
what the build does with them.

Everything below is measured from the stylesheet the live site serves, so you can repeat it:

$ curl -s https://nakodo.app/ | grep -o '/_next/static/immutable/chunks/[a-z0-9-]*\.css' | sort -u
/_next/static/immutable/chunks/10cobkkku2ww9.css
/_next/static/immutable/chunks/3lx8-zlp93hp8.css
Enter fullscreen mode Exit fullscreen mode

6,159 and 148,223 bytes, 154,382 together. Inside, five cascade layers in this order: properties, theme,
base, components, utilities. The utilities layer is 110,382 bytes of it. My own file contributes six rules
to that layer, 611 bytes to components, and 24,680 bytes that are in no layer at all, which turns out to be the
interesting part.

The six

/* Condensed caps for column headers, kickers and tags. */
@utility label-caps {
  font-family: var(--font-archivo), ui-sans-serif, system-ui, sans-serif;
  font-stretch: 75%;
  font-weight: 600;
  font-size: 11px;
  line-height: 16px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* Hatching always means "locked" or "not yet observed", and always comes with text. */
@utility hatch {
  background-image: repeating-linear-gradient(45deg, var(--hatch) 0 1px, transparent 1px 4px);
}

@utility rule-double {
  border-top: 3px double var(--rule);
}

/* "No. 0147", "Guide 03" */
@utility catalogue {
  font-family: var(--font-plex-mono), ui-monospace, monospace;
  font-weight: 500;
  font-size: 12px;
  line-height: 16px;
  letter-spacing: 0.02em;
}

/* Notched specimen tag: a notch on the left edge and a punched hole. */
@utility specimen-tag {
  position: relative;
  clip-path: polygon(7px 0, 100% 0, 100% 100%, 7px 100%, 0 50%);
  padding-left: 14px;
}

/* Paper grain for marketing panels only. */
@utility grain {
  background-image: url("data:image/svg+xml,%3Csvg ... feTurbulence ... %3E");
}
Enter fullscreen mode Exit fullscreen mode

Two of them (label-caps, catalogue) are type specimens: seven declarations that always travel together, so
writing them as seven classes would be seven chances to forget one. Two are marks from the brand spec
(rule-double, specimen-tag). Two are textures (hatch, grain).

Usage, counted as class tokens in the source and then on the live HTML:

Utility Source files Tokens in source Home page /pricing /terms
label-caps 27 49 17 18 6
hatch 25 70 6 0 0
catalogue 12 27 19 0 33
rule-double 8 9 1 1 16
grain 4 6 1 0 0
specimen-tag 1 2 1 1 0

Two things fall out of counting the live HTML rather than the source. hatch is on exactly one public page, the
home page, and zero of the other 75: the texture that carries a meaning lives almost entirely behind sign-in.
And grain is on 73 of the 76 public pages but never once on its own. Every live instance reads
class="panel grain ...", because the only places it is used are the cyanotype call-to-action panel at the foot
of every content page, the equivalent section on the home page, and the sign-in page's side panel. The three
public pages with no grain are /pricing, /terms and /privacy, and /pricing is the interesting one: it has
a panel, it just has no texture on it. Nothing says whether that is a decision or an omission, which is reason
enough to write it down.

A custom utility is sorted by what it does, not by its name

This is the thing I did not expect. Tailwind does not append custom utilities to the end of the output. It sorts
them into the same stream as the generated ones, by property. Here is each of mine with the rule that precedes it
in the shipped CSS:

Utility Preceded by
specimen-tag .sr-only
grain .bg-white
hatch .grain
label-caps .align-top
catalogue .label-caps
rule-double .ring-inset

specimen-tag lands among the positioning utilities because its first declaration is position: relative. The
two textures land among the background utilities, the two type specimens among the font utilities, rule-double
among the borders. That is the right behaviour and it has a consequence worth knowing: when two classes in the
same layer set the same property, the later rule in the file wins, so whether grain beats a Tailwind
background-image utility applied to the same element is decided by where the build put it, not by anything you
can see in the class list. I would not rely on either order.

@utility also makes variants work, so hover:hatch and sm:grain would compile. I grepped every class string
in the app for a variant on any of the six: zero. The whole reason to prefer @utility over a plain class in
@layer components is a feature I am not using, and the honest version of this post is that five of the six could
have been @layer components rules with no visible difference today.

The rule that depends on being unlayered

hatch is the one with a meaning attached. The brand rules say hatching means "locked" or "not yet observed", and
never decoration: a hatched circle where a creator's avatar would be, hatched bars for months with no data. Six of
them on the home page, every one aria-hidden with the real words next to it.

A diagonal stripe pattern is also exactly what disappears in Windows high contrast mode, where the browser throws
away background images. So the file has this, right after the @utility block:

@media (forced-colors: active) {
  .hatch {
    background-image: none;
    border: 1px dashed CanvasText;
  }
}
Enter fullscreen mode Exit fullscreen mode

In the shipped CSS that rule sits about 10,000 bytes past the closing brace of the utilities layer:

$ curl -s https://nakodo.app/_next/static/immutable/chunks/10cobkkku2ww9.css \
    | grep -o '@media (forced-colors:active){\.hatch{[^}]*}}'
@media (forced-colors:active){.hatch{background-image:none;border:1px dashed canvastext}}
Enter fullscreen mode Exit fullscreen mode

It is not inside @layer utilities, and that is the whole reason it works. An unlayered rule outranks every rule
in every layer, whatever the selectors say, so .hatch written at the top level beats .hatch generated into the
utilities layer without needing a second class, an !important, or a more specific selector. Had I written the
override inside @utility hatch as a nested media query, it would have been one more rule in the same layer and
the winner would depend on source order within the layer. Being outside the layers is the mechanism, not an
accident of where I typed it.

There is a twin to this pattern in the drawings, and the two do not quite agree. The illustration library draws
its own hatching as an SVG pattern:

/** Hatching: 0.75-unit lines at 45 degrees, 3 units apart. */
export function Hatch({ id }: { id: string }) {
  return (
    <pattern id={id} width="3" height="3" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
      <line x1="1.5" y1="0" x2="1.5" y2="3" stroke={LINE} strokeWidth="0.75" />
    </pattern>
  );
}
Enter fullscreen mode Exit fullscreen mode

Every drawing is a 96 unit grid rendered at one of three sizes: 64, 120 or 240 pixels. So the drawn hatching is
2 px apart at the small size, 3.75 px at the medium and 7.5 px at the large, while the CSS hatching is a 1 px line
every 4 px at all sizes. They match at exactly one of the three, and it is the medium. Nobody will ever notice,
and I would rather know it than not: if the CSS pattern ever changes, it has to change against a drawing whose
spacing scales and whose does not.

What the compiler did to the one class I wrote by hand

.panel is not a utility. A cyanotype panel is a context, not an effect: it repaints its own text, borders,
links, hatching and the line art inside it by swapping variables, so it belongs in @layer components where it
can be overridden by any utility placed on the same element.

I wrote one rule. Five came out:

@layer components{
  .panel{background-color:var(--panel);color:var(--panel-foreground);--foreground:var(--panel-foreground);
         --illo-line:var(--panel-foreground);--illo-fill:var(--panel);--hatch:var(--panel-foreground)}
  @supports (color:color-mix(in lab, red, red)){
    .panel{--hatch:color-mix(in oklch, var(--panel-foreground) 32%, transparent)}}
  .panel{--muted-foreground:var(--panel-muted);--border:var(--panel-line)}
  @supports (color:color-mix(in lab, red, red)){
    .panel{--border:color-mix(in oklch, var(--panel-line) 35%, transparent)}}
  .panel{--rule:var(--panel-foreground);--link:var(--panel-foreground);--ring:var(--color-cyano-300)}
}
Enter fullscreen mode Exit fullscreen mode

Lightning CSS saw two color-mix() values, wrote an opaque fallback for each, then guarded the real value behind
@supports, and splitting the rule in three was the only way to keep the declaration order intact. The whole
components layer is 611 bytes and it is one class in five pieces.

This is not special treatment for my class. There are 56 @supports (color:color-mix(in lab, red, red)) blocks
in the stylesheet, one for every opacity modifier anywhere in the app: bg-muted/50, ring-ring/50,
decoration-link/40 and so on. The feature test is worth a second look too. It asks about color-mix(in lab, ...)
while every value it guards is color-mix(in oklch, ...), because support arrived for all the colour spaces at
once and lab is the cheapest proxy to write.

Worth reading the fallbacks, though, because they are not transparent versions of the same colour: without
color-mix support, --hatch becomes the panel's full-strength foreground instead of 32 per cent of it, and
--border goes fully opaque. On a panel that means hatching four times too dark, which is the sort of thing that
looks like a rendering bug rather than a graceful degradation. Every browser I target has supported color-mix
since 2023, so what this really buys is two extra rules and a worse-looking fallback for a browser nobody is
using. I am leaving it: it costs 180 bytes and arguing with the compiler costs more.

If you want to look

The grain texture is the only one of the six that is visible without reading markup: an inline SVG feTurbulence
at 5 per cent alpha, 390 bytes in the shipped CSS, one element per page, and it is why the cyanotype panel on
nakodo.app is not flat. (The word means something else 30 lines away in the drawings: the
mascot's branch has a grain path, two strokes at 0.75 units, which is wood grain and no relation.) label-caps is every small uppercase label on
the pricing page, 18 of them. rule-double is the 3 px double rule above
"Questions" at the foot of every content page, such as
this guide. And the hatching is the circle standing in for a
creator's picture in the sample row on the home page, plus the empty bars in the little chart beside it.

Six utilities, 611 bytes of components, 24,680 bytes outside every layer. The one lesson I would pass on: decide
whether a thing is an effect or a context before you decide where to write it, and if you need a rule to beat a
generated utility, the layer it is not in does more work than the selector you reach for first.

Top comments (1)

Collapse
 
alohci profile image
Nicholas Stimpson •

I advise against putting rules in no layer, because it makes further overrides unnecessarily hard. Instead, create a new layer and put your overriding rules in that. So long as the new layer is first mentioned after your tailwind import(s), the rules in the new layer will take precedence over those in the existing layers just like rules in no layer. But if you want to override your override, you can simply repeat the process by creating yet another new layer. And continue that process ad infinitum.