CSS refactoring
Posted in thoughts
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…
