Hello 👋
My name is Keith Cirkel
And I am bad at CSS
Hello everyone! Just a small procedural note, the QR codes on the
top right change for each slide, but each one will be
k-9-l-dot-i-o-slash-a-word. If you don't catch them in time then
k-9-l-dot-i-o-slash-bad-css is where you'll find this whole slide
deck.
With that said, my name is Keith Cirkel.
And I'm bad at CSS.
👈
Look, it's true, it even says so on my profile.
👆
👈
But it didn't used to be this way, in fact as recently as January
'24 I just used to be bad at JavaScript and HTML.
It has taken me the past couple of years to get to the point where
I can confidently say that I'm bad at CSS.
My journey to being bad at CSS started roughly here. I wrote a small
"lexer" for CSS in JavaScript. It helped me understand CSS syntax.
And I turned that into a much bigger project called csskit, which is
a fully fledged build chain for CSS. It parses, it minifies, it does
LSP stuff... we'll get to all this later.
And I got a new job in 2025, now I'm a engineer on Firefox, so some
of my time is spent implementing new CSS features from the ground up
in one of the three major browser engines.
So recently we shipped anchor positioning and I worked a bit on that,
like this implementation for anchor center.
To be bad at CSS, you must first understand CSS.
You have to know where the bodies are buried.
My point is, being bad at CSS doesn't just happen. Anyone can be mid
at CSS, and there are a lot of people who are good at CSS - but
those are different things.
Being bad at CSS is it's own skill.
You too, can be bad at CSS!
But it's okay, because you don't have to do it the hard way, you can
take the abridged route by having someone else who is bad, show you
how to be bad.
div {
width: var(--width, 10px);
}
So let's look at some CSS.
Here's some CSS, simple enough. How do we turn this into something the browser can reason about?
🚨 Parsing theory tangent! 🚨
Okay let's talk about parsers for a second
Raw Text
h2 { width: var(--width,10px) }
Tokenization
h2
{
width
:
var(
--width
,
10px
)
}
So if you're building a parser, you want to take a blob of text like
this, and turn it into something useful you can reason about. You
could just use a pile of regexps but you probably want to be smarter
than that.
So the first step is to tokenize, or lex. We take each _set of
characters_ and turn it into a token, identifier, punctuation,
whitespace, and so on. This can be done with a small finite state
machine that just looks at every character, and decides if that's
part of the current token, or if we should start a new token.
Tokenization
h2
{
width
:
var(
--width
,
10px
)
}
AST "Component Values"
h2
{
width
:
var(
--width
,
10px
)
}
From here we can turn this stream of tokens into a tree, and we can
reason about this tree more than just text. This is typically called
an Abstract Syntax Tree. CSS calls this "Component Values" and this
is important because component values is an intrinsic part of the
language. Notice here we can do things like matching parenthesese.
Unbalanced parens will make for an invalid tree.
HIR (Higher-Order Intermediate Representation)
h2
{
width
:
var(
--width
,
10px
)
}
From an AST we might start tagging or wrapping nodes in these higher
level nodes, which can help expose all kinds of new contexts, such
as whether or not something is a selector, or a declaration. This is
useful for lots of reasons, for example in selectors and custom
properties whitespace matters, whereas whitespace in a block
doesn't.
CSS Syntax 3
Now all of this is defined in a standalone spec called CSS Syntax 3.
If you follow this document, and implement everything it says,
you'll get to the point where you have something that can parse CSS
Component Values - but doesn't have smarts about what a rule is, or
what's a valid selector, and so on.
If you're really interested in this stuff, I would recommend it. You
could write a parser in a language of your chosing in a weekend or
two, it's very well documented. You could probably also feed this
into an LLM and it might make a parser for you in a matter of
minutes.
<ident-token>
--
-
a-z A-Z _ or non-ASCII
escape
a-z A-Z 0-9 _ - or non-ASCII
escape
❌ 4px
✅ px4
❌ -4px
✅ -four-px
❌ -4-px
--4px
❌ ⁃⁃foo
-⁸px
❌ --⁂⁂⁂
-̶̡̰̤̰̣̫̗̻̦̘͔̺̪̰̙͕̋͗̈́͗ͅ-̷̢̫̜̻͚̩̭̫̔̋̎̑͜͠z̴̧͓̪̱͈̯̗̣̖̪̼̣̱͇͓͑̓̒̽ả̴̛͓̻̖̾̑̐͂̾̈́̋͂̒̐̆̈́̄̕l̸̨̧̢͇͍̦̱͉̖̮̫̳͕̖̖̝̽̒̇͒͗́͋͐͝͝g̴̳̻̼̳̭͉͔͖͖̜̼̻̳̹͍̈́̈́̅͗̈́͋̽̀̅̑̚͜͜o̸̯̭̖̫̦̭̬̲̞̳̗̗̫͔̍͗͛̒͊͋̒̄̓̓̇̈́̈̐̚̕
❌ p x
\000070 \000078
And what you'll find in that document are a bunch of these railroad
diagrams. These look really intimidating but they're very
informative and once you learn how to read them you'll understand
exactly what characters are allowed where.
So this one is for identifiers in CSS. As you can see an identifier
can start with a dash, or an a-z or a group called non-ASCII, or an
escape, then it must be followed by one or more a-z, 0-9, non ascii,
or escape sequences.
This can cause some surprising things to be considered identifiers...
CSS escaping rules means all of these parse the same
<number-token>
+
-
digit
.
digit
digit
.
digit
e
E
+
-
digit
❌ four
✅ 4
❌ 4/2
✅ -2.0
❌ 2.8.3
✅ 2e4
❌ 2e4e5
+2.0000e+0002
Number tokens also have similar rules, with some surprises. For
example the word four is obviously an identifier, but the number 4
is a number. 4/2 is not valid, that's a number, delimeter, number.
However -2.0 is valid.
Multiple decimals is no longer valid though, that's two number
tokens!
We can use scientific notation though, so 2e4 is allowed, but 2e4e5
is a dimension, the e5 becomes a dimension unit, like px
CSS escaping rules means all of these parse the same
h2 { width: 200px }
h2 { width: 200.000000000px }
h2 { width: 2e2px }
h2 { width: +000000000000002e+00000000000002px }
h2 { width: +000000000000002e+00000000000002\000070 \000078 }
h2 { width: +000.00020e+000000006\000070 \000078 }
👐🏻 Demo Time! 👐🏻
npx csskit@latest expand --escape-idents -c "h2{width:200px;}"
h2{width:200px;}
...becomes...
\000068 \000032 {
\000077 \000069 \000064 \000074 \000068
:+2.00000000000000e+0000000002\000070 \000078 ;
}
Demo page:
data:text/html,<h2 style="border:solid">Hello NN1</h2><style>\000068 \000032 { \000077 \000069 \000064 \000074 \000068 :+2.00000000000000e+0000000002\000070 \000078 ; }
So we can take this to it's logical conclusion and use a hidden feature in csskit to expand
a chunk of CSS into this mess.
❝But Keith that's stupid, I'm never going to use it❞
You, probably
Fun fact, in writing this talk, I managed to discover this bug in Firefox. So this kind of work has its uses!
And this exercise in expanding and compacting is really helpful for iterating on a minifier, because it catches
all kinds of issues, like csskit now has smarts about when to preserve a + sign and when to strip it out, which
is surprisingly nuanced and was only caught by experimenting with the expand tool.
<label class="block">
<span class="after:content-['*'] after:ml-0.5 after:text-red-500 block text-sm font-medium text-slate-700">
Email
</span>
<input type="email" name="email" class="mt-1 px-3 py-2 bg-white border shadow-sm border-slate-300 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:ring-sky-500 block w-full rounded-md sm:text-sm focus:ring-1" placeholder="you@example.com" />
</label>
.after\:content-\[\'\*\'\]::after {
--tw-content:"*";
content:var(--tw-content)
}
.after\:ml-0\.5::after {
margin-left: .125rem;
}
.after\:text-red-500 {
color: rgb(239 68 68 / var(--tw-text-opacity, 1));
}
So here's tailwind, an apparently popular production CSS framework. It uses escape characters all over the place, in order to provide a microsyntax in the class attribute.
But, okay, let's ignore characters, focus on tokens
AST "Component Values"
div
{
width
:
var(
--width
,
10px
)
}
Remember how I said component values were important, and we're going to come back to them?
If you've ever written CSS before, you've likely dealt with component values, even if you didn't realise it.
--white
:
rgba(
255
255
255
/
1.0
)
rgba(
255
255
255
/
1.0
)
And that's because CSS variables _are_ component values. The grammar, or spec, for a custom declaration is
to simply save all of the token stream into the value.
NEXT
Then when you use var you're effectively saying "insert this token stream".
NEXT
And the token stream gets transposed over the top. Just like that.
color
:
rgb(
var(
--rg
)
var(
--b
)
)
This gives you unique capabilities, compared to other languages, as your variable can be multiple tokens, not just a value but part of what you might consider a value.
For example we can do silly stuff like make two variables for 3 colors.
NEXT
Then we can supply rgb() as a function to a color, and the internals of that can be two variables.
NEXT
And the browser will just transpose the token stream, and re-parse, and now it knows that's a valid value and it'll use it.
CSS "Booleans"
:root {
--condition: 0; /* or 1 */
}
.box {
/* This would be 100px */
width: calc( 1 * 100px );
/* This would be 0px */
width: calc( 0 * 100px );
/* This would be either 0px or 100px */
width: calc( var(--condition, 0) * 100px );
}
if (myCondition) {
el.style.setProperty('--condition', '1');
} else {
el.style.setProperty('--condition', '0');
}
So we can use these token streams - these variables - in other expressions like calc.
This means you can already do boolean logic. For example if I have a --condition property,
I can set it to 0 or 1 and use math - multiply a value by that 0 or 1 - to get a result out.
Toggle
Boolean toggle in action
.notification {
--unread: 0;
/* Badge only visible when --unread is > 0 */
&::after {
counter-reset: variable var(--unread);
content: counter(variable);
width: calc(min(var(--unread), 1) * 22px);
height: calc(min(var(--unread), 1) * 22px);
background: deepskyblue;
border-radius: 100%;
overflow: hidden;
}
}
--unread: 0
Inbox
--unread: 1
Inbox
--unread: 4
Inbox
Your condition doesn't just have to a be a boolean though, you can use math to clamp it to,
so for example here we've ogt a notification dot, and rather than adding a class to the button
when we've got notifications, we can just give the unread count directly to CSS, and use the min
function to clamp it to either 0 or 1. We then multiply this clamped number by --size and it will
only show the dot when unread count is above 0.
We can also use the `counter-reset` and `content` rules to actually show the number in the dot.
Fallback Chaining
.card {
/* How it works */
--fallbacks: var(--the-thing, the-fallback);
/* Check multiple variables, use first valid one */
background: var(--card-bg,
var(--surface-bg,
var(--default-bg, white)));
/* Component → Theme → System → Hardcoded */
color: var(--card-text,
var(--theme-text,
var(--system-text, black)));
}
CSS variables also have fallbacks. Perhaps you've encountered this.
var() takes two arguments, the first is the name of the custom
declaration but the second is an _additional_ token stream to fall
back to if that variable couldn't be found or if it was invalid.
Fallback values can themselves contain var() calls, letting you
build a cascade of preferences. This is great for design systems
and such, where you want component-level overrides, theme defaults,
and system fallbacks.
The "Space Toggle" Hack
button {
--OFF: ; /* Just a space - valid token! */
--ON: initial; /* "initial" = forces fallback */
--is-raised: var(--OFF);
border: 1px solid var(--is-raised, rgb(0 0 0 / .1));
background: var(--is-raised,
/* ON value : OFF value */
linear-gradient(hsl(0 0% 100% / .3), transparent));
box-shadow: var(--is-raised,
/* ON value : OFF value */
0 1px hsl(0 0% 100% / .8) inset, 0 .1em .1em -.1em rgb(0 0 0 / .2));
}
button:hover { --is-raised: var(--ON); }
Button
But we can also use our new found knowledge of tokens to abuse this
fallback. This pattern is known as the the space toggle hack. Lea
Verou has a great demonstration on her blog about this. So because
variables can be any token, this means a space is a valid value.
So here --OFF encodes a single space. --ON is the value `initial`.
Initial is a special CSS wide keyword, and this will cause the value
to be a guaranteed invalid value, meaning that when you try to use
it, it'll fallback to the second.
When prepended to a value, the space disappears and you get your value.
But "initial" makes a variable invalid, triggering the fallback chain.
This gives us true if/else conditional logic in pure CSS!
... it's kind of useless
button {
border: 1px solid transparent;
background: hsl(200 100% 50%);
box-shadow: 0 .1em .1em -.1em rgb(0 0 0 / .2);
}
button:hover {
border-color: rgb(0 0 0 / .1);
background: linear-gradient(hsl(0 0% 100% / .3)) hsl(200 100% 50%);
box-shadow: 0 1px hsl(0 0% 100% / .8) inset;
}
Button
Of course you could just write your CSS like a normal person but
where's the fun in that.
Now that we know variables are powerful...
Let's abuse them for color math!
So we've seen that CSS variables can act as booleans, conditional toggles,
and form fallback chains. Now let's combine all of this with calc() to do
some real math.
label {
--color: rgb(var(--r) var(--g) var(--b));
background-color: var(--color);
color: contrast-color(var(--color));
}
What I'd really like to do is use the contrast-color function
You're all smart folk though, you might see where this is going...
We can use calc
❝But Keith that's stupid, I'm never going to use it❞
You, probably
This is the website github.com, looking at the label picker. This label picker does this technique almost
exactly. There are each of the r, g, and b channels, and the perceived-lightness variable is the Rec709 luma coefficient - 2126, 7152, 0722.
This is in production today, and has been for several years, you can go visit GitHub, open the devtools, and see this working on every label on GitHub.com.
Let's do more colo(u)r math(s)!
But this time with 100% less usefulness!
/* Linearize sRGB to Linear-RGB */
--rt: clamp(0, sign(calc(var(--rs) - 0.04045)), 1);
--gt: clamp(0, sign(calc(var(--gs) - 0.04045)), 1);
--bt: clamp(0, sign(calc(var(--bs) - 0.04045)), 1);
--rl: calc(
(1 - var(--rt)) * (var(--rs) / 12.92) +
var(--rt) * pow(calc((var(--rs) + 0.055) / 1.055), 2.4)
);
--gl: calc(
(1 - var(--gt)) * (var(--gs) / 12.92) +
var(--gt) * pow(calc((var(--gs) + 0.055) / 1.055), 2.4)
);
--bl: calc(
(1 - var(--bt)) * (var(--bs) / 12.92) +
var(--bt) * pow(calc((var(--bs) + 0.055) / 1.055), 2.4)
);
What if we took our input colors, and instead of deriving Luma, we linearized them!
/* Convert LinearRGB to a generic LMS (Long, Medium, Short) color space */
--l: calc(0.4122214708 * var(--rl) + 0.5363325363 * var(--gl) + 0.0514459929 * var(--bl));
--m: calc(0.2119034982 * var(--rl) + 0.6806995451 * var(--gl) + 0.1073969566 * var(--bl));
--s: calc(0.0883024619 * var(--rl) + 0.2817188376 * var(--gl) + 0.6299787005 * var(--bl));
--lp: pow(var(--l), 0.3333333333333333);
--mp: pow(var(--m), 0.3333333333333333);
--sp: pow(var(--s), 0.3333333333333333);
Then we apply some matrix math to those values. To get the LMS color space values for these.
/* Convert LMS to Oklab (L, a, b) */
--okl: calc(0.2104542553 * var(--lp) + 0.7936177850 * var(--mp) - 0.0040720468 * var(--sp));
--oka: calc(1.9779984951 * var(--lp) - 2.4285922050 * var(--mp) + 0.4505937099 * var(--sp));
--okb: calc(0.0259040371 * var(--lp) + 0.7827717662 * var(--mp) - 0.8086757660 * var(--sp));
/* Oklab to Oklch: C = sqrt(a^2+b^2), h = atan2(b, a) */
--okc: sqrt(calc(var(--oka) * var(--oka) + var(--okb) * var(--okb)));
--okh: atan2(var(--okb), var(--oka));
/* Final Oklch color declaration */
--oklch: oklch(var(--okl) var(--okc) var(--okh));
And then from there we did some more matrix math to get the OKlab values
/* BOOLEAN: determine if the Luminance Value was light or not */
--is-light: clamp(0, calc((var(--okl) - 0.5) * 1000), 1);
--dir: calc(1 - 2 * var(--is-light));
/* Calculate the alternate Lightness for Oklch */
--okl-alt: clamp(0, calc(var(--okl) + var(--dir) * 0.55), 1);
--oklch-alt: oklch(var(--okl-alt) var(--okc) var(--okh));
And then from there we did some more matrix math to get the OKlab values
❝But Keith that's stupid, I'm never going to use it❞
You, probably
Thank you for listening
Questions welcome
Keith Cirkel
🦋 @keithamus.social
🐘 @keithamus.indieweb.social
🐙 github/keithamus
▶️ youtube/keithamus
Thanks for your time listening to me. You can find me on the various
socials. I go by keithamus almost everywhere including bluesky,
mastodon, github and youtube where I live stream browser
development!