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. My first migration gave me an opportunity to redesign the site and brush up my Sass code, but I've been adding custom properties, moved to Dart-Sass, and played around with new technologies without taking the time to breathe.
I used Claude to help me build a styleguide, and run an inventory of my Sass variables and CSS classes which was helpful and fun to watch.
So, no visual redesign this time, but a lot of refactoring behind the scenes. It feels good too.
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);
Switch to the light-dark() function
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…

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.