Picking a blue is a pleasant afternoon. The trouble arrives six months later, when someone needs the button background a little darker for a pressed state, finds no name for it, and types a hex. A dozen of those and the system is decoration.
The grammar
Three parts, and two of them are optional. --brand-secondary-pressed
Role comes first, and it says what the colour is for. Then level, which is its weight inside that role. Then state, which is when it applies.
The primary level has no suffix at all, so ring: a name gets longer as the thing gets quieter. --card is the common case and it stays short. --card-secondary is the quieter well inside it.
There is a fourth suffix. -foreground names whatever sits on a colour, so text on a solid brand button is --brand-foreground. It travels with the fill. Nobody has to work out whether white still passes after somebody warms up the blue.
embed: image:https://framerusercontent.com/images/oPGKmCVR46NwO5dt7MArRZBzYEw.png
caption: one name, in its three parts
Three layers
Background, card, foreground. That is the entire surface model.
Background is the page. Flat content and empty space, sitting on nothing. A card is anything contained, which covers a panel, a table, a dialog and an input. Foreground is text, icons and strokes, and it never carries a fill of its own.
Strokes live inside foreground rather than beside it. --stroke is a card's edge and --stroke-secondary is the divider inside it, so a border and a rule are the quiet end of one ladder. I did try a fourth layer for lines. It bought me nothing except a fourth set of values to keep in step across two themes.
A raised surface is a card with a bigger shadow. There is no elevation ladder in the names.
embed: image:https://framerusercontent.com/images/ZqnO1vT4zZVtgfNAYQ9U0mkpw.png
caption: eight names doing an entire card
Anything that acts owns hover and pressed
Brand has three levels and they land straight on the three buttons everyone builds. A solid fill, a tinted fill, a text-only treatment. --brand, --brand-secondary, --brand-tertiary.
Each of those owns two states. So the tinted button, hovered, is --brand-secondary-hover, and there is nowhere else it could be. Focus sits outside this on purpose. It is a ring rather than a fill, so it gets one name, --focus, and no states of its own.
Success, warning, danger and info work the same way. They carry hover and pressed because a danger button exists, and they carry a -foreground because text sits on them.
embed: image:https://framerusercontent.com/images/11q6og9llx5S5Be0CRjD79wnfo.png
caption: three levels, two states each, and the same suffix in every row
One set for everything switched off
A disabled button, the border on a read-only input and a greyed-out line of text do not each need their own definition. Two names cover all of it. --inactive for a fill or a stroke, --inactive-foreground for text or an icon.
This is the one place the pattern breaks, and it breaks deliberately. Inactive is a style applied to a thing rather than a state every colour has to repeat. Adding -inactive to nineteen colours would have given me nineteen greys, and eighteen of them would have been the same grey.
The same names, other values
Light values sit on :root. Dark values sit under both hooks a project might use, because half the ecosystem sets a class and half sets an attribute. A theme switch flips one of them, and nothing else on the page changes.
That last sentence is the part I get wrong most often. On this site the inline code style carried a raw #333 with no dark value, so on a dark article it measured 1.04:1 against the surface behind it. Grey text on almost exactly the same grey. It shipped, and it stayed shipped, because a hex has no theme and nothing in the file admits that.
The toggle itself was worse. It switched exactly eleven tokens, from a list baked into a script. Six tokens created after that list was written carried on following the operating system instead. So a visitor on a dark Mac who chose light got a page that was mostly light, with the logo drawn in its dark ink. That bug only exists in the state where someone's machine and their choice disagree, which is a state no screenshot ever shows.
embed: image:https://framerusercontent.com/images/zv2oBRd49HifziK2xmjViXBrH8.png
caption: the same eight names, both themes
What a name never says
Three things stay out, and each one is a rule I broke before I wrote it down.
No hue. --blue-500 is a primitive. Primitives generate the named values and then stay out of every component. --brand can be any colour, and it needs to be, because it has changed twice on this site.
embed: image:https://framerusercontent.com/images/y6cBFi889Cnck0CKikNNQHXIko0.png
caption: the three exclusions, which are the useful half




