CSS is great… but it’s often not obvious

The tailwind home page is fantastically well designed. In my case it immediately tells me what tailwind is, why it exists, and why I do not want to have any part of it.

The “front-end” is fundamentally driven by three core languages: HTML, Javascript, and CSS. Each of them is absolutely fantastic in its own way but also has a lot of baggage. The thing about baggage, though, is that like emotional baggage it’s far from being uniformly a Bad Thing. A lot of what we call “wisdom” is actually “baggage”. We just take the bits of baggage we really like and give it another name and pretend it’s a different category of thing, and then just treat “baggage” as being a catch-all phrase for stuff that comes with experience that we don’t like.

A big reason we ignore good advice when we are young and value it when we are old is that you need experience—baggage—to have enough context to recognize and understand good advice. It also means that an even further piece of wisdom we gain as we get older is that giving good advice is mostly futile unless we recognize that it will likely be ignored, why it will be ignored, and account for this as well (perhaps holding our tongues).

Am I giving this advice because I expect it to make this young, unwise person’s life to be better or because I will enjoy seeing them ignore it and then maybe go into therapy and flagellate themselves for ignoring it when ignoring it was what they were going to do?

But, enough about my marriage.

Tailwind CSS

Tailwind CSS is getting a lot of notice lately. I first heard about it several years ago when it was spontaneously brought up by a job interviewer referring to an example of a hot new technology that was clearly really great unlike all those other hot new technologies that turn out to be cancerous nests of dependency and become employment programs for mediocre developers who remember how they work and/or a pain in the ass tjat need to be migrated away from. You know like pretty much all front-end libraries that aren’t simply ignored or forgotten immediately (like my front-end libraries).

My reaction, on looking into it, was that Tailwind CSS was, um, bad.

It’s bad because it treats class names as being descriptive instead of semantic. Which is literally its whole point! So instead of a class representing the meaning or purpose of a thing, it simply represents an atomic characteristic of its appearance.

There are always edge cases. E.g. I frequently use toolbar and elastic classes. A toolbar is both a presentational idea and a semantic idea. An elastic element in a toolbar is purely presentational (a gap between groups of buttons might be elastic, but so might (say) an input field that I want to give all the leftover space to).

But, at bottom, one of the great foundational ideas of the web is that HTML should be semantic (i.e. as much as possible, just the HTML should explain the information design of the content. <h1> is better than <div style="font-size: 48px; font-weight: 700"> and <strong> is better than <b> and that CSS (and classes as an important link between the two) should leverage this idea.

The other thing about Tailwind is that it means having a giant collection of descriptively named classes either don’t convey any useful information or are actively misleading (if it turns out that font-medium doesn’t actually make the text medium (is that a font name or a size? I’ll guess size but ¯\_(ツ)_/¯).

In other words, Tailwind looks like a language (‘font-medium’) while actually just being a labeling system designed by Captain UsuallyObvious. You know, like CSS but not powerful.

<span class="font-medium">I am medium… sized?</span>

Versus this super human-hostile unreadable low-level code…

<span style="font-size: 16px">I am 16px</span>

But wait, you ask, what if I wanted to redefine “font-medium”? OMG, I reply, you mean something as horrible as this?

<span style="font-size: var(--font-size-medium, 16px)">I am medium sized</span>

Sorry, I went crazy there and used a properly descriptive name and gave it a default in case, you know, the expected definitions are completely MIA.

It’s sad how the low-level code is less expressive, more ambiguous, less flexible, and less powerful than this powerful new CSS framework, and doesn’t fail gracefully. Oh wait, sorry, it’s the complete other way around.

Why CSS is seldom done “right”

It’s hard to get CSS right. Part of this is because most web developers these days don’t actually know how to deal with CSS (or HTML or Javascript) because they live in this weird world where they’re writing their code in a fake programming language (e.g. TSX) that is transpiled into a several other languages (the core of which is higher level and more powerful than the one they think they’re coding in) which they don’t understand. C programmers know that they’re writing in a fake language that gets turned into assembler. Most front-end developers don’t actually realize that, say, “Typescript is a superset of Javascript” is a bald-faced lie.

Part of this is because CSS isn’t “Turing complete” (a fancy phrase meaning it doesn’t do loops or conditions and thus you can’t implement a Turing machine (i.e. a general purpose computer with… let’s pretend… unlimited memory) in it and thus suffer from the halting problem) and thus it’s not a “real” programming language and that it’s therefore “easy” and CSS specialists literally get paid less than “real engineers”. So really smart engineers aren’t interested in dealing with CSS because it’s way harder than something they can get paid more to do.

Try telling an XSL developer that non-Turing complete programming languages are “easy”.

Also, my Regex skills are widely considered godly (even though I don’t use forward- and back- references) and most hardened colleagues who see my nastier regexes run away screaming. Regex is also not Turing-complete. (That said, hiring great engineers to write Regex code for your organization is insane… which reminds me of a funny story about Haskell…)

Getting CSS right is like happiness. It’s not something you realize at the time so much as something you realize in retrospect. b8rjs got CSS right because we never had major CSS issues with the very complex product we built with b8rjs and similarly with the even more complex product we built with the predecessor of b8rjs at USPTO. It remains to be seen if xinjs has gotten CSS right because it hasn’t been in production very long and the project is, by my standards, not so complex. Still, about as complex than anything I see being done with Tailwind.

This reminds me of Extreme Programming which gave as an example of a “complex project” that was developed in only a few sprints using XP something anyone who knew Filemaker Pro could whip up in an afternoon, iteratively improve with a few minor tweaks over a week of usage, and then swap out for something more robust if it ever became necessary.

Towards Modern CSS

One of the problems with web technologies is that they evolve slowly and yet incredibly fast. Just as it took the programming world about ten years to go from “Javascript is a toy” to “Javascript is a hot mess” (the “Javascript in a Nutshell” years) to “Javascript is actually kind of fantastic” (the “Javascript: The Good Parts” and jQuery years) and then cycle between “hot mess” (Angular) and “kind of fantastic” (“Angular: You Have Ruined Javascript”) a few times, CSS goes through waves of frustration (don’t use “<b>” use “<strong>”), adoption (Dreamweaver replaces “<b>foo</b>” with “<span style=”font-weight: bold”>foo</span>”), joy (CSS Zen Garden), and so on…

For me the key inflection points were:

  1. CSS Zen Garden shows that clearly, CSS is capable of amazing stuff but it’s too weird.
  2. jQuery’s selectors are insanely cool. Oh wait, those are just CSS selectors. That’s how CSS works?! OMG!
  3. CSS is too general (I still kind of think this)
  4. You can do hardware-accelerated 3D using CSS? Thank you Apple!
  5. Wait, that shit runs super slow on the garbage computers most people have. Also, too much.
  6. flex is pretty awesome, but grid is clearly capable of amazing stuff, but it’s too weird
  7. Are css-variables God?
  8. web-components completely destroy my mental model of CSS
  9. oh, the shadow DOM is optional… why didn’t they explain this more carefully?
  10. css-variables are definitely God

Leaning into what works…

I’m going to discuss my current approach to CSS, and let’s preface this with the caveat that this is the approach I’m using with xinjs, it isn’t as battle-tested as b8rjs’s approach, and it’s still evolving. It’s also not a panacea. This shit is hard to do right in a sustainable manner, accidents happen, your mileage may vary, and you’re not paying me. Take this for what it’s worth.

Everything needs to be built on CSS Variables

And this is hard.

Just as the dual hierarchies of CSS and the DOM interact to—usually—make things simpler but sometimes make things much weirder and more complex, both have become multiple intertwined hierarchies.

CSS has joined the and now you have n! problems phase. But again, this is CSS. You aren’t escaping it. It’s there.

In the case of CSS we have CSS-variables being inherited through the CSS hierarchies and the DOM (note the plurals) but we also have mixed hierarchies of DOM and shadowDOM. (And I really do not know how the shadowDOMs play with each other and frankly don’t want to. Maybe I’ll be writing an article in five years about how much I freaking love or hate this but right now I do not want to deal.)

Consider this example…

<style>
  :root {
    --bg: yellow;
  }

  body {
    background: var(--bg);
  }

  input {
    --bg: white;
  }
</style>
<label>
  <span>This is an input</span>
  <input>
</label>

The key observation here is that the input is supposed to have a white background, but doesn’t.

I actually had to build this to be sure I wasn’t going to be talking nonsense and then because the tool I used happened to define the same variable names at :root because I’m a creature of habit I spent quite a few minutes making sure I wasn’t completely wrong and needed to delete this post to avoid professional embarrassment.

This stuff is getting crazy complex even for simple cases.

The point here is that CSS definitions flow both through the CSS hierarchy and the DOM. So the definition of --bg may get changed by input but the rule that governs it is applied before we get to the input so the fact that the input redefines --bg does nothing.

If the rule were instead * { background: var(--bg); } then the input’s background would change. This is powerful and not super intuitive. And it’s only going to get worse. But if you lean hard into CSS variables and pull back on doing stuff (other than messing with CSS variables) in your various CSS rules, everything actually gets easier. Eventually.

But, and here’s the thing, Tailwind doesn’t help with any of that. You’re going to need to deal with this shit at some point because it’s how the browser works. The thing is, most of the stuff Tailwind does well is just stuff that CSS variables do better.

A good technology makes things that used to be hard easier, and things that used to be impossible doable, while not making simple things complex. CSS Variables check all these boxes, but what they don’t do is play well with convoluted solutions made without CSS Variables. In particular, CSS variables play very well with the shadow DOM, which is a Good Thing, and they allow a lot of complexity and inefficiency to go away (and this will get better once color calculations are added to CSS) but even now they’re pretty damn good.

What Seems to Work

  1. Try to keep everything as simple as possible (as always).
  2. Use css rules to describe styling intention (I want this text to be bigger than that text, I want this to be twice as round as that) or styling contexts (this is a minor context in which everything can be smaller).
  3. Use css variables to fill in styling particulars (e.g. basic spacing is 10px, the standard body font should be in Roboto, Sans-serif).
  4. Use Javascript to make everything simpler and easier to code, test and verify where possible.
  5. Only use the shadowDOM when necessary, and then see the previous rules.

So we end up with things like this for items 1 to 3.

<style>
:root {
  --font: Roboto, sans-serif;
  --heading-font: var(--font);
  --font-size: 16px;
  --h1-scale: 2;
  --spacing: 10px;
}

h1 {
  font-family: var(--heading-font);
  font-size: calc(var(--font-size) * var(--h1-scale));
  margin: calc(var(--spacing) * var(--h1-scale)) 0;
}
</style>

This is a deliberately superficial example. The point is that as this gets more realistic, the advantages grow. E.g. you can drive the line-height and margin of the h1 rule from the existing variables. And in fact the heading rules can be mostly identical, with just the differences (say, --text-scale) changing and powering font-size, line-height, margin, and so on.

<style>
:root {
  --font: Roboto, sans-serif;
  --font-size: 16px;
  --text-scale: 1;
  --margin-scale: 0;
  --spacing: 10px;
  --text-color: #222;
}

* {
  font-family: var(--font);
  font-size: calc(var(--font-size) * var(--text-scale));
  --margin: calc(var(--spacing) * var(--margin-scale)) 0;
  margin: var(--margin);
}

h1 {
  --text-scale: 2;
  --margin-scale: 2;
}

h2 {
  --text-scale: 1.5;
  --margin-scale: 1;
}

h3 {
  --text-scale: 1.25;
  --margin-scale: 0.5;
  display: run-in;
}

h1, h2, h3 {
  --font: var(--heading-font, "Fancy Pants", sans-serif);
}
</style>

And then we get syntax sugar like this in Javascript:

const { elements, vars, css, makeVars } = 'xinjs'
const { style, div } = elements

document.head.append(css({
  ':root': makeVars({
    font: 'Roboto, sans-serif',
    fontSize: '16px',
    textScale: 1,
    marginScale: 0,
    spacing: '10px',
  }),
  '*': {
    fontFamily: vars.font,
    fontSize: `calc($vars.fontSize} * ${vars.textScale})`,
    '--margin': `calc(${vars.spacing} * ${vars.marginScale})`,
    margin: vars.margin
  }
  h1: {
    '--text-scale': 2,
    '--margin-scale': 1,
  }
...

Which in turn allows things like this to be built incredibly simply…

import { Component, elements, vars } from 'xinjs'

const { slot } = elements

export class Toolbar extends Component {
  styleNode = Component.StyleNode({
    ':host': {
      alignItems: 'center',
      display: 'flex',
      gap: vars.spacing25,
      padding: `0 ${vars.spacing50}`
    }
  }}
  content = () => slot()
}

export const myToolbar = Toolbar.elementCreator({tag: 'my-toolbar'})

xinjs’s syntax sugar really comes into its own with its syntax sugar for sizes, dimensions, and colors. Instead of having huge lists of definitions for things that might be useful, you can simply define core values you know you’ll use and build things based on them, e.g. if you define --spacing: 10px then you can use vars.spacing150 and have a future-proofed value that will be calc(var(--spacing) * 1.5) versus needing to define a ton of values that need to be looked up constantly and most of which are never used. (And yes, you can make negative multiples using _.)

This is particularly egregious in many CSS libraries where a ton of colors are defined, most never get used, but you never have the one you need. With xinjs you can leverage the built-in color library to just define core colors and riff on them, so defining --accent-color: #f00 lets you refer to vars.accentColor_o20 and get a 20% opaque version of var(--accent-color) (and, hopefully, when the browser vendors implement color calculations the implementation will just change to use them and work even better).

There’s some catches

I should add that just writing the preceding section opens several cans of worms that I consider to be thoughts-in-progress.

Should the variable name be font or font-family?

Should we use margin or margin-left etc.?

I’m actually inclined to use the shortcut variables for all margins, padding, radii, etc. but never use the font shortcut. But that’s just me. In fact, using margin-left can eliminate a lot of ugly template strings which is a Good Thing on its own.

But it feels super ugly to be explicitly defining four properties instead of just one, especially when usually one or two work. And things like resetting everything using the shortcut variable then just specifying specific variables while more efficient can be problematic and it’s even uglier. Ugh.

Doesn’t fontSize: vars.fontSize kind of make my skin crawl a little? It does. It’s kind of in Tailwind font-medium territory (although in this case it’s a “tautology” rather than a renaming).

And, here’s the real kicker, is h1 { fontSize: ... } the right approach? Or would * { fontSize: vars.fontSize } and h1 { '--font-size': ... } be better? I’ll hazard that the latter is actually is better from a code sustainability point of view. I’m kind of leery of it because it seems like we’re evaluating a rule separately for every DOM node rather than once, but I have no real idea as to whether this actually matters in practice. How does one practically test such things?

Intuitively, having a rule saying that every element’s fontSize is var(--font-size) and then just changing that in every style rule seems inefficient. Isn’t that just adding a layer of indirection to everything? But in practice it gets around constant workarounds like:

button {
  --button-color: var(--accent-color, var(--text-color));
  color: (--button-color);
}

svg.icon {
  color: var(--heading-color);
}

button svg.icon {
  fill: var(--button-color);
}

Can be this instead:

* {
  color: var(--text-color);
}

button {
  --text-color: var(--accent-color, var(--text-color));
}

svg.icon {
  fill: var(--text-color);
}

In a sense, we’re only saving one line of code in the second case, but the second case is completely general. There will be far fewer bad surprises. But if we do this, it seems like we’re inevitably going to end up with a very complicated rule for * that gets evaluated on every damn element. This seems bad. And, in practice, it’s mostly because the CSS attributes for SVGs come from Mars.

My gut feeling right now is that we should route a few key things, like color, background, fill, stroke, margin, padding, box-shadow, and border through the * { } rule and do everything else the old way. (By the way, box-shadow is there because that’s how I draw borders around elements, which is a whole other can of worms.)

On the other hand, the number of DOM elements one has to style is typically not growing very quickly because our eyeballs aren’t gaining resolution at crazy rates, so perhaps more importantly, xinjs‘s syntax sugar works better for assigning CSS properties than setting variable values because that was the approach I assumed we’d be using (and it maps well onto the DOM APIs). If we’re going to be reassigning variables at lot and setting properties relatively infrequently maybe we need to rethink this. The obvious syntax sugar for setting variables would be something like __varName: vars.varName50 but now linters are going to scream about the underscores and I don’t like having to deal with that crap.

Anyway, I haven’t figured all the ramifications out yet, but the basic stuff works incredibly well, and there’s more work needed to make it even better. And with that said, here’s my current list of known unknowns:

  1. Should we just define * { } to assign a certain set of properties in a very standard way and then lean heavily into the CSS-variable inheritance pattern as much as possible or is this going to prove a maintenance nightmare or lead to performance issues?
  2. By paving the path of assigning CSS-variables and simple calc() expressions are we creating problems down the road?

I will state that if these are going to be problems, I see no sign of them yet. Back when I was at Google one of the strongest opponents of CSS variables (and proponents of SCSS) went to enormous lengths to create performance test cases to prove that CSS-variables were the devil, would blow out source code size and cause performance hit, and ended up becoming one of our strongest allies after it turned out the opposite.