Skins

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.

Two axes, not one

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.

Live

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.

Skin for the preview
Mode for the preview

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.

Sunken: inputs, wells, inset regions.
primarysecondaryaccent
info — surface, solid and foreground.
success — surface, solid and foreground.
warning — surface, solid and foreground.
danger — surface, solid and foreground.
selectorfieldboxelevation 2

Inverse block, for high contrast.

Every skin, both modes

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.

default light

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.

Sunken: inputs, wells, inset regions.
primarysecondaryaccent
selectorfieldboxelevation 2

Inverse block, for high contrast.

default dark

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.

Sunken: inputs, wells, inset regions.
primarysecondaryaccent
selectorfieldboxelevation 2

Inverse block, for high contrast.

sepia light

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.

Sunken: inputs, wells, inset regions.
primarysecondaryaccent
selectorfieldboxelevation 2

Inverse block, for high contrast.

sepia dark

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.

Sunken: inputs, wells, inset regions.
primarysecondaryaccent
selectorfieldboxelevation 2

Inverse block, for high contrast.

What a skin is

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.

The rules

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Everything a skin must define

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.

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

Surfaces and content

--color-surface
page background
--color-surface-raised
cards, popovers, anything above the page
--color-surface-sunken
inputs, wells, inset regions
--color-surface-inverse
high-contrast blocks
--color-on-surface
primary text
--color-on-surface-muted
secondary text
--color-on-surface-subtle
placeholders, disabled text
--color-on-surface-inverse
text on --color-surface-inverse

Borders

--color-border
default
--color-border-subtle
dividers, low-emphasis separation
--color-border-strong
emphasis, focus-adjacent

Brand roles

Each with a hover, an active and a foreground.

--color-primary
--color-primary-hover
--color-primary-active
--color-on-primary
--color-secondary
--color-secondary-hover
--color-secondary-active
--color-on-secondary
--color-accent
--color-accent-hover
--color-accent-active
--color-on-accent

Status roles

A solid colour, a foreground for it, and a tinted surface.

--color-info
--color-on-info
--color-info-surface
--color-success
--color-on-success
--color-success-surface
--color-warning
--color-on-warning
--color-warning-surface
--color-danger
--color-on-danger
--color-danger-surface

Utility

--color-focus
focus ring
--color-shadow
the colour elevation is built from
--color-overlay
dialog and drawer backdrops
--color-selection
::selection background

Shape

--radius-none
--radius-sm
--radius-md
--radius-lg
--radius-xl
--radius-full
--border-width
--border-width-strong

Optional — derived, override only to change the relationship

Defined in tokens/shape.css against the scale above, so a skin gets them free.

--radius-selector
checkboxes, radios, switches (defaults to --radius-sm)
--radius-field
inputs, buttons, pressables (defaults to --radius-md)
--radius-box
cards, dialogs, panels (defaults to --radius-lg)
--focus-ring
from --border-width-strong and --color-focus

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.

The skins that ship

Default skinalso the default, on :root

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"]

Sepia — warm paper

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"]

Build a third one