Typography
Four families. Which is used where is a component decision.
- --font-primary
- headings
- --font-secondary
- body copy and UI
- --font-tertiary
- decorative, accents, pull quotes
- --font-mono
- code, tabular figures, keyboard keys
A skin is one selector block that redefines 51 custom properties. There is no plugin API, no configuration file and no build step — if you can write a CSS rule, you can write a skin.
A skin is an identity: a palette, four font families, a set of radii. A mode is light or dark, and only ever those two. They are orthogonal — any skin works in either mode, and neither attribute needs to know about the other.
Each colour is declared once, as light-dark(light, dark), and resolves against the color-scheme the mode sets. That is why a skin is 51 lines rather than twice that, and why the control in this site's top bar is a single toggle: there is one thing to choose.
A skin must never set color-scheme itself. That belongs to the mode, and a skin that sets it pins every descendant to one half of every light-dark() — the toggle goes on working everywhere except inside that skin, which is the worst way for it to fail.
Pick a skin and a mode to apply them to the preview below. Both are selectors, so scoping them to a <div> works exactly like scoping them to <html> — that is the whole of what the two attributes do.
Surfaces and text
Primary text sits on the page surface. Muted text is secondary, and subtle text is a placeholder.
Raised surface
A card, a popover, anything above the page.
Inverse block, for high contrast.
The same markup, once per skin per mode, each inside an element carrying a different pair of attributes. Sepia is the interesting one: it is not a lightness, it is an identity, and it has a dark mode of its own — which is exactly the distinction a one-axis system cannot make.
Surfaces and text
Primary text sits on the page surface. Muted text is secondary, and subtle text is a placeholder.
Raised surface
A card, a popover, anything above the page.
Inverse block, for high contrast.
Surfaces and text
Primary text sits on the page surface. Muted text is secondary, and subtle text is a placeholder.
Raised surface
A card, a popover, anything above the page.
Inverse block, for high contrast.
Surfaces and text
Primary text sits on the page surface. Muted text is secondary, and subtle text is a placeholder.
Raised surface
A card, a popover, anything above the page.
Inverse block, for high contrast.
Surfaces and text
Primary text sits on the page surface. Muted text is secondary, and subtle text is a placeholder.
Raised surface
A card, a popover, anything above the page.
Inverse block, for high contrast.
This file defines no values. It documents what a skin is, so that writing a new one is filling in a known list rather than reverse-engineering the library. THE NAMES IN THIS FILE ARE PUBLIC API. THE REST OF THE CSS IS NOT. Everything a skin is required to declare — the 51 level 1 tokens below, plus the five optional derived roles — is a name someone else's stylesheet depends on the moment they write a skin. Renaming one breaks every skin ever written against this library, so it is a breaking change and waits for a minor release before 1.0 and a major after it. See CHANGELOG.md. Level 2 component tokens (`--btn-bg` and its neighbours) are public too, and documented per component. The structural scales in `tokens/` are not part of a skin, but they are named in the manifest and treated the same way. What is *not* public: the internals of any component's stylesheet, class names the manifest does not list, and the order of rules within a layer. Those can change in a patch. TWO AXES, NOT ONE A **skin** is an identity: a palette, four font families, a set of radii. A **theme** is a mode, and it is only ever `light` or `dark`. <html data-skin="sepia" data-theme="dark"> They are orthogonal. Any skin works in either mode, and the two attributes never need to know about each other. Earlier versions of this library had one axis, which forced `bedrock` and `bedrock-night` to exist as two unrelated themes when they are plainly one identity in two modes. Neither attribute is required. With no skin you get the default one; with no theme you get whatever the reader's system asks for. A SKIN DECLARES EACH TOKEN ONCE [data-skin="sea"] { --color-surface: light-dark(oklch(99% 0 0), oklch(17% 0.01 250)); --color-on-surface: light-dark(oklch(22% 0.01 250), oklch(95% 0 0)); … } `light-dark()` resolves against the element's computed `color-scheme`, which the mode sets — see modes.css. So one declaration covers both modes, and a skin is 51 lines rather than 102. This library previously banned `light-dark()`, on the grounds that it caps a system at two themes. That reasoning was right when themes carried identity. Now that identity lives in the skin, the cap applies to *modes*, and two is exactly how many there are. The ban is lifted for skins and holds nowhere else.
Rule 1
A skin sets **only** custom properties. Never `color-scheme` — that belongs to the mode, and a skin that sets it breaks the light/dark toggle for everything inside it.
Rule 2
Every colour is explicit on both sides of `light-dark()`. There is no runtime derivation: what you read is what renders. Components may use `contrast-color()` as a *fallback* inside `var()` for a foreground a skin chose not to define — a safety net, not the mechanism.
Rule 3
Both modes must pass contrast. A dark mode is not an inversion: brand colours generally have to lighten, because a 45% hue that reads on white disappears on near-black. The token audit checks every pair in both modes.
Rule 4
A skin may redefine font families and radii, not only colours. Two skins with the same palette and different typography are legitimately different.
51 tokens, in 7 groups. Write all of them and the skin is complete; leave one out and it falls back to whatever the previous skin set, which is the bug that produces a dark palette with one stubbornly white panel.
Four families. Which is used where is a component decision.
Each with a hover, an active and a foreground.
A solid colour, a foreground for it, and a tinted surface.
Defined in tokens/shape.css against the scale above, so a skin gets them free.
Override one only to change the *relationship* — square boxes with pill fields, say — not merely the scale, which the radii already handle. They are declared at zero specificity, so restating one inside a skin block wins. Everything else — spacing rhythm, type scale, motion, elevation geometry — is structural, lives in tokens/, and a skin does not touch it. The one exception is `--space-scale`, the density multiplier.
Applies with no `data-skin` attribute, so the library works out of the box, and can also be named explicitly to force it back on a subtree. Each token is declared once. The left half of every `light-dark()` is the light mode, the right half the dark one — see skins/_contract.css. Dark is not an inversion of light. Two things change on purpose: - Raised surfaces get *lighter* than the page rather than darker. In light mode elevation reads as a shadow; in dark mode shadows are nearly invisible, so lightness carries the hierarchy instead. - Brand colours lift and lose a little chroma. A 53% blue that reads well on white vibrates on near-black, and one that stays put becomes unreadable. Light-mode `--color-primary` sits at 53%, not the 55% it shipped with, and `--color-focus` moves with it because they are the same blue: at 55% the brand on `--color-surface-sunken` measured 4.48:1 against a 4.5 floor, so any brand text on a well — a nav's current item, a `.link` in a `hero` band, a 404 page — was two hundredths short and looked fine. 53% puts it at 4.87. 54.5% would have cleared the floor by 0.09, which is not a margin a conformance claim should rest on. Colours are OKLCH: lightness is perceptual, so a whole ramp moves by editing one channel without the hue drifting.
src/css/skins/default.css — [data-skin="default"]
It exists to prove the contract holds. Light and dark are the pair every library gets right by accident; a skin that is neither, and that also swaps the type for a serif stack and tightens the radii, is what shows whether the token list is actually sufficient. Its dark mode is not a dark grey with a warm tint — it is the same paper seen by lamplight: a deep brown ground with the ink lifted to a warm off-white. Writing it was the point of the exercise, because "a skin must work in both modes" is easy to state and easy to skip.
src/css/skins/sepia.css — [data-skin="sepia"]