CSS refactoring

A side effect of migrating to Kirby was the opportunity it gave me to revisit my markup and my CSS.

I've been carting a lot of legacy code from way back then, and have just been adding onto it.

Replacing Sass with CSS

One area I was keen to explore was how to reduce my dependency on Sass. My code has a mix of Sass variables, mixins and custom properties.

I was already using somewhat modern Sass (@use/@forward, sass:math, color.channel — no more @import), but I wanted to replace the scaffolding Sass was responsible for.

Converting Sass variable to custom properties

Eg. $article-padding: 1.25rem was used many times, often as v.$article-padding * -1 for negative margins. As a custom property it becomes overridable per component — which is what several media queries currently do by hand:

--article-padding: 1.25rem;
margin-inline: calc(var(--article-padding) * -1);

This led me to discover a number of Sass variables, mixins and functions that were never used…

The RGB channel-splitting triad

I was using a pattern to derives six variables via color.channel() (--light-theme-color--red/green/blue and the dark equivalents) purely so that alpha can be applied to a themed colour, and that pattern was repeated many times.

--link-bg-color: rgba(
  var(--light-theme-color--red),
  var(--light-theme-color--green),
  var(--light-theme-color--blue),
  0.25
);

color-mix() does this natively:

--link-bg-color: color-mix(in srgb, var(--light-theme-color) 25%, transparent);

light-dark()

Back when I wrote my dark mode toggle, every dark-mode rule was written twice and speculates that "a @custom-media-style approach … could halve it once browser support allows."

That support has arrived, and the answer turned out to be light-dark().

The per-channel companions these used to carry are gone: color-mix() a colour and a percentage directly, so the channels no longer need splitting to apply alpha.

Each token is declared once, with both values. light-dark() picks a slot from the used color-scheme, so the dark theme no longer needs a parallel copy of this list. Tokens with one value are simply not wrapped.

:root {
  color-scheme: light dark;
  --light-theme-color: #{v.$light-theme-color};
  --dark-theme-color: #{v.$dark-theme-color};
  --header-bg-color: light-dark(var(--light-theme-color), var(--dark-theme-color));
  --header-title-color: light-dark(white, var(--light-theme-color));
  --header-meta-color: var(--light-theme-color);
  --link-color: #b4e7f8;
  --link-color-alt: #ffda92;
  --link-bg-color: light-dark(
    color-mix(in srgb, var(--light-theme-color) 25%, transparent),
    color-mix(in srgb, var(--dark-theme-color) 50%, transparent)
  );
  --text-color: light-dark(#424b54, rgb(221, 221, 221));
  /* …one line per token */
}

/* the JS override just flips the resolution mode */
[data-theme="light"] { color-scheme: light; }
[data-theme="dark"]  { color-scheme: dark; }

This required no JavaScript change. theme-toggle.js always resolves the attribute to a concrete light or dark, so mapping [data-theme] → color-scheme covers the user override, and bare color-scheme: light dark covers the system preference — which means the .no-js duplication under @media (prefers-color-scheme: dark) disappears too.

Reduce the number of breakpoints needed

There is no CSS-native replacement, and this should be stated plainly so it stops being re-investigated: custom properties are invalid inside @media, @container and @supports conditions, and @custom-media is a Working Draft with zero browser implementations.

Switching to fluid type in an Utopia-clamp approach helped me eliminate the fixed $font-size-* scale and the many media queries that just step through it (approximatively 15).

A fluid($min, $max, $from: 20rem, $to: 48rem) function* generates the clamp up to 48 rem at which point the font size doesn't change anymore. All four arguments must be in rem.

// ---------------------------------------------------------------------------
// Fluid type
//
// Linear interpolation between two sizes across a viewport range, as a single
// clamp(). Replaces a font-size that steps at a breakpoint with one that ramps.
//
// ---------------------------------------------------------------------------
@function fluid($min, $max, $from: 20rem, $to: 48rem) {
  @if math.unit($min) != "rem" or math.unit($max) != "rem" {
    @error "fluid() needs rem sizes, got #{$min} and #{$max}.";
  }
  $slope: math.div($max - $min, $to - $from); // unitless
  $intercept: $min - $slope * math.div($from, 1rem) * 1rem;
  // 4dp is well below a subpixel at any realistic viewport; keeps output short.
  // Rounding the bounds too, since the $font-size-* scale is built by
  // multiplication and lands on values like 4.0000000001rem.
  $lo: math.div(math.round($min * 10000), 10000);
  $hi: math.div(math.round($max * 10000), 10000);
  $r: math.div(math.round($intercept * 10000), 10000);
  $v: math.div(math.round($slope * 1000000), 10000);
  @return clamp(#{$lo}, #{$r} + #{$v}vw, #{$hi});
}

(*) Inspired from Brecht De Ruyte's Smashing Magazine article.

Container queries for components whose breakpoints really ask "how wide is my container?", the percentage ladders (45% → 30% → 25%) become repeat(auto-fit, minmax(…, 1fr)) and need no breakpoint at all.

Dead selectors

I was using a number of @extend and @each that were generating selectors that were never used.
eg. @extend .plain ×7 becomes @extend %plain the placeholder the class itself extends.

Cascade layers

I've been wanting to dip my feet into cascade layers for a while now, but I was wary that retrofitting them into legacy code might not be that easy. I wanted to try to better organise my code, and in doing so, get rid of most !important on the way. Specificity is a wild beast to tame.

While refactoring my vendor code, I soon realised that @use is not allowed inside a @layer block, so partials cannot be wrapped at the import site. Digging around I discovered that meta.load-css() can be used inside @layer, so a partial can be layered at the import site after all. This was important regarding the vendor CSS. I didn't want to add an @layer vendor { opener and a matching closing brace in each vendor file. It felt wrong.

styles-vendor.scss now declares the layer once:

@use "layers";
@use "sass:meta";

@layer vendor {
  @include meta.load-css("vendor/koenoe/cocoen");
  @include meta.load-css("vendor/andreknieriem/simple-lightbox");
  @include meta.load-css("vendor/labnol/light-youtube-embed");
  @include meta.load-css("vendor/luwes/light-vimeo-embed");
}

The caveat is that load-css() does not expose its variables, mixins or functions to the caller. This is okay in my case, but it's not entirely future-proof.

Later layers win regardless of specificity, so a low-specificity utility beats a high-specificity component without needing !important.

Load order of the three stylesheets (styles, styles-vendor, styles-deferred) no longer decides precedence: whichever loads first establishes this order, and each bundle re-declares it so it holds however they arrive.

My layers are:

@layer reset, base, vendor, components, utilities;

Unlayered rules beat every layer. Anything added outside a @layer block silently jumps to the top of the cascade, so I must remember to keep new rules inside one.

Font loading

The last part I revisited was font loading. I was preloading the most important font files, and inlining their CSS properties. That was an artefact of the time I was inlining all my critical CSS. I've reverted back to a single CSS file since. So I move the six @font-face rules into the Sass as _fonts-local.scss, loaded first in the base layer. I also cleaned up the properties, dropping local() and only providing .woff2 format.

A novelty was to try to provide a metric-matched fallback to minimise CLS during the swap period. My CLS was already close to zero, but hey I've always wanted to give this a try (I use a subset version of each font family to reduce size even further).

I built one fallback per family, based on the dominant weight (PT Sans 400, Roboto Condensed 300) using Arial, which is darn close to universal.

size-adjust

The size-adjust CSS descriptor for the @font-face at-rule defines a multiplier for glyph outlines and metrics associated with this font. This makes it easier to harmonize the designs of various fonts when rendered at the same font size.

I used a little help from Claude here. Derived with fontTools from the shipped .woff2 and macOS Arial, using Fontaine's method: size-adjust is the ratio of frequency-weighted advance widths, and the three overrides are the font's own hhea divided by that ratio so size-adjust does not scale them twice.

This is the larger lever. swap currently renders the fallback at different metrics and reflows when the webfont lands — that reflow is the CLS. A size-adjusted fallback makes the swap dimensionally neutral:

@font-face {
    font-family: "PT Sans Narrow Fallback";
    src: local("Arial");
    size-adjust: 78.56%;
    ascent-override: 129.58%;
    descent-override: 35.13%;
    line-gap-override: 0%;
  }

  @font-face {
    font-family: "Roboto Condensed Fallback";
    src: local("Arial");
    size-adjust: 87.75%;
    ascent-override: 105.72%;
    descent-override: 27.82%;
    line-gap-override: 0%;
  }

Afterthoughts

Was it necessary? Maybe. Was it fun? Definitively. I managed not to break anything (that I've noticed or measured). Things from the outside look just the same as before, but under the hood things are much neater and organised.

I also got to play with new CSS properties and experiment (and break stuff). It was a net positive in terms of personal learning and my website got marginally faster.

This is typically the kind of exercice I would love to do with some of my client projects, but in this day and age, it's getting harder to justify from a business stand point. Despite it contributing to increase the skills of the team, the motivation and excitement contribute to the well-being of the team too. It's important to play, experiment and be proud of what one produces, even if nobody notices it.

Hell, my friends and colleagues cannot justify attending a web conference anymore, so imagine spending time on refactoring…

😻 See you soon in Freiburg 🇩🇪

Recent Logs — or the next 10 entries

Well, they might not be all that recent. You'll find the older entries in the archives.

More entries »

Athens and Paros  — Greece / 2026

A couple of days in Athens to explore Pangrati and Kypseli, followed by two weeks downtime in Paros.