Actual CSS

Color guide

Theming principles read off two preset palettes — ocean as the cool one, sunset as the warm one — in light and dark at the same time, as four data-theme islands on one page. Every hex, chroma and hue below is measured from the rendered page at load, so the figures cannot drift away from the themes they describe.

The switcher changes the page chrome only. The islands inside each figure carry their own palette and ignore it — which is the first thing this guide is about.

Chapter one

The neutral ramp

Most of a page is neutral. What those neutrals are made of decides whether the palette reads as one thing or as a brand colour dropped onto grey furniture.

Figure 

Neutrals lean

The same eight roles in both palettes, lightest first. Nothing here is grey: each rung carries a trace of its palette's temperature, and the two ramps are the same structure at two different temperatures.

ocean · light

sunset · light

--surface is the canvas, --surface-raised the elevated one, --surface-subtle the working level for fields and hovers. --text, --text-muted and --text-subtle are the three ink tiers, and --neutral is the intent, not a ramp rung — it sits on the ramp here because it has to belong to the same family.

Figure 

Chroma is a curve, and light is not dark reversed

OKLCH chroma per role, on one shared 0–0.04 scale so the four columns are comparable. Two things show up that a token list cannot say: the temperature is a dose that varies per rung, and the two schemes spend it in opposite places.

ocean · light

ocean · dark

sunset · light

sunset · dark

In light, the paper is nearly achromatic and the chroma climbs all the way to the ink: the darkest text is the most tinted thing in the palette. In dark it inverts — the ink goes to a flat near-white and the temperature moves into the paper and the borders, peaking at --border — the last rung before the ink.

That is why a dark theme is not a light theme with the lightness reversed. Both schemes are authored separately, as light-dark() pairs in the same declaration.

bun run report:neutral-ramp prints these figures for every preset, plus the lightness a tint costs at equal contrast. It is a survey, not a gate: leaving the trend is a legitimate palette decision.

Figure 

Ambient hue is not the brand hue

Where each palette parks its tokens on the hue circle, measured. The neutrals are not the primary's hue copied down the ramp: they hold a narrow band of their own, and the shadow ink is placed on purpose rather than inherited.

ocean · light

sunset · light

Angles are OKLCH, the space report:neutral-ramp reports in — they do not match the HSL numbers written in the theme sources, and the strip above is built in OKLCH so a marker lands on the colour it names.

As measured here, ocean straddles: its neutrals sit a few degrees either side of the primary, and the shadow ink is pushed about 30° beyond all of them. sunset fans out in one direction: the primary is the reddest thing in the palette and every neutral lies further toward amber, with the shadow ink tucked in between. Two different answers to the same question.

Either way the relationship is a coordination, decided once by the theme author and recorded in the theme's own values. Nothing in the core derives a neutral or an intent from --primary at runtime.

Figure 

Pure white is allowed

ocean tints its canvas and keeps --surface-raised at pure white. sunset tints both. Two defensible answers, and the contract does not pick one for you.

ocean · white card on a cool canvas

Raised surface

The card is #FFFFFF. Against the tinted canvas it reads cooler than it is.

sunset · warm card on a warm canvas

Raised surface

The card carries its own amber. The palette wants matter in every pixel.

Simultaneous contrast does the work in the first case: a neutral card on a cool ground makes the ground read cooler, without a single blue pixel in the card. "Never use pure white" is a rule of thumb, not a law — what matters is that the ramp around it belongs to one family.

Chapter two

Relations, not stored colours

The tempting move is to materialise every result as its own token: a subtle variant per intent, a border per weight, a shadow per size. Actual stores fewer colours and more relationships between them.

Figure 

.soft is a recipe, not six more tokens

Six intents, two palettes, twelve tinted surfaces — and no --primary-subtle, --danger-subtle or --success-subtle anywhere. The soft treatment mixes the intent into the local surface, so it lands correctly on whatever it is dropped onto.

ocean · light

Primary Secondary Success Warning Danger Neutral

sunset · dark

Primary Secondary Success Warning Danger Neutral
/* the whole recipe, from core/variants.css */
--ui-bg:     color-mix(in oklab, var(--surface) var(--soft-bg-mix), var(--intent));
--ui-border: color-mix(in oklab, var(--surface) var(--soft-border-mix), var(--intent));
--ui-fg:     var(--intent-soft-fg, color-mix(in oklab, var(--text), var(--intent) var(--soft-fg-mix)));

A stored --primary-subtle would be one colour against one assumed background. This one resolves against the surface it is actually sitting on, in whichever island, in whichever scheme — twelve results above from three declarations.

Figure 

One dial changes the whole language

--soft-border-mix decides how much of the rim survives. Pointing it at --soft-bg-mix resolves the border to the fill, and every soft treatment in the island becomes a flat tint — from one declaration, with no component rule rewritten.

default · 65%

Primary Success Danger
Contoured soft surfaces.

rim resolved to the fill

Primary Success Danger
Flat soft surfaces.

It reaches the default .badge and .alert plus every explicit .soft, and nothing else — a component sitting on its own surface keeps its --border rim.

Figure 

Shadow is the colour of absent light

The theme decides what colour the darkness is; the elevation tokens decide how much of it there is. One --shadow-color per palette, and every shadow in the island follows.

ocean · shadow ink

 
--shadow

Resting elevation.

sunset · shadow ink

 
--shadow

Resting elevation.

--shadow and --shadow-popout are derived from --shadow-color with color-mix(), on every theme boundary. A palette changes the ink once instead of restating a shadow list — and it is free to place that ink outside its own primary's hue, as figure 03 shows.

Two levels, not three. A hero card that wants more is an application decision, not a missing global token.

Figure 

Semantics stay recognisable, and still belong

An error imported from a component library that assumed a neutral field reads as a foreign object on a tinted one. Both palettes below keep danger unmistakably red — and both nudge it into the family they live in.

ocean · light

 
Enter a valid email address.

sunset · light

 
Enter a valid email address.

Intents are curated pairs in each theme's own values, not mechanical derivatives of --primary. That is what lets ocean place success in a cool green and sunset place it in a warmer one while both keep meaning the same thing.

Chapter three

Treatments and states

Colour stops being a flat fill as soon as something is a gradient, is hovered, is focused, or is a control the browser paints itself. None of those is a stored colour either — each is derived from the same tokens at the point of use.

Figure 

A gradient is two stops from the same palette

The gradient preset turns every solid button into a gradient without touching a single component rule: it re-derives --ui-bg from the intent the button already carries. Same markup as the two flat palettes beside it.

ocean · flat

sunset · flat

gradient · same markup

/* themes/gradient.css — both stops come from the intent,
   so danger gradients red and primary gradients blue with
   no per-intent rule. */
--ui-bg: linear-gradient(
  180deg,
  color-mix(in oklch, white 15%, var(--intent)),
  var(--intent)
);

sunset · derived stops, then hand-picked ones

The second bar is a perfectly reasonable #f8fafc → #cbd5e1 lifted from a generic grey scale. On a warm canvas it belongs to nothing — the surface it sits on is the only thing that decides whether a gradient reads as part of the page or pasted onto it.

The preset also re-runs the recipe over a darkened base for hover rather than reusing the resting fill, so the gradient shape survives the state change instead of flattening.

Figure 

Interaction belongs to the palette too

Three colours that only exist while something is being used. All three are declared on :root, [data-theme], so they are recomputed at every island rather than inherited already resolved. Hover and tab through the rows below.

ocean · light

surface, then + --hover-overlay
--focus, --focus-ring

sunset · light

surface, then + --hover-overlay
--focus, --focus-ring

--hover-overlay is about four percent of ink. A button paints it as box-shadow: inset 0 0 0 999px over whatever fill it already has, so one token covers a solid, an outline and a table row without a hover colour per variant. The second chip above is that same recipe applied to --surface, which is the only way to see four percent at all.

--focus-ring is not authored: it is color-mix(in oklch, var(--focus) 30%, transparent), re-derived on every boundary. Override --focus and the ring follows; there is no stale second value to keep in sync.

accent-color is the one that catches people out. The checkbox and range above carry no Actual class at all — the browser paints them, and follows the island only because accent-color: var(--primary) is re-declared on every [data-theme]. It inherits as a computed value, so a single declaration at the root would paint every island with the root's primary.

Chapter four

Islands

Everything above is rendered on one page, at the same time, with no media query and no JavaScript deciding colours. That is the island contract doing the work.

Figure 

Four palettes, one document

data-theme selects the palette. color-scheme selects which half of every light-dark() pair resolves. The two are independent, so a preset gives you four surfaces from one attribute pair.

ocean · light

Healthy

ocean · dark

Healthy

sunset · light

Healthy

sunset · dark

Healthy
<div data-theme="ocean" style="color-scheme: light">…</div>
<div data-theme="ocean" style="color-scheme: dark">…</div>

Without data-theme, the default palette advertises color-scheme: light dark and follows the OS. A dark-only theme declares color-scheme: dark, a light-only one light. Browsers without light-dark() get the light fallback, which is why every preset states it first.

Figure 

An island recomputes what it derives

A boundary is not just a set of colours: everything the core derives from them is re-declared there too. A nested island overrides its parent's, and every derivation follows the nearest one.

ocean

Selection ink, shadow, focus ring and the native accent-color all belong to this palette.

sunset · dark, nested inside ocean

Same markup. Every derived alias recomputed at this boundary.

Theme-derived aliases — --shadow, --shadow-popout, --backdrop-fill, --selection-bg, --heading, --state-selected, --indicator-ring — are declared on :root, [data-theme] rather than on :root alone. A custom property resolves its var() references where it is declared, so an alias declared only at the root would be inherited already resolved and would ignore the island entirely.

data-theme is an active boundary on every element that carries it, never a place to park a value. A <option value="ocean"> or a hidden input holding a theme name wants data-theme-value or the control's own value — unless you actually want that element painted, which is exactly how a theme swatch is built.

Chapter five

Two compositions

Principles are easy to agree with one swatch at a time. These are the same two fragments, byte for byte, rendered in all four islands.

Figure 

Demo one — a plan card

Canvas, raised surface, border, three ink tiers, a soft badge, a solid action and a resting shadow. No colour is written in the fragment; all of it comes from the island.

ocean · light

Team Popular

€24/mo

Everything in Starter, plus shared workspaces and audit history.

Billed annually. Cancel anytime.

ocean · dark

Team Popular

€24/mo

Everything in Starter, plus shared workspaces and audit history.

Billed annually. Cancel anytime.

sunset · light

Team Popular

€24/mo

Everything in Starter, plus shared workspaces and audit history.

Billed annually. Cancel anytime.

sunset · dark

Team Popular

€24/mo

Everything in Starter, plus shared workspaces and audit history.

Billed annually. Cancel anytime.

Figure 

Demo two — a status panel

The harder case: three semantic states next to each other, on a tinted field, with a subtle surface underneath. This is where an imported palette usually starts to float.

ocean · light

99.9%Uptime
142msp95
3Alerts
Deployment completed.
Certificate expires in 6 days.
Two workers failed their health check.

ocean · dark

99.9%Uptime
142msp95
3Alerts
Deployment completed.
Certificate expires in 6 days.
Two workers failed their health check.

sunset · light

99.9%Uptime
142msp95
3Alerts
Deployment completed.
Certificate expires in 6 days.
Two workers failed their health check.

sunset · dark

99.9%Uptime
142msp95
3Alerts
Deployment completed.
Certificate expires in 6 days.
Two workers failed their health check.

Chapter six

Notes

Two review habits, and two rungs the token set deliberately does not have.

Figure 

The greyscale check

Hue removed from the same panel. If the hierarchy and the states survive, colour was reinforcing them; if the panel goes flat, colour was carrying them alone.

as authored

Two workers failed their health check.

Secondary description.

Tertiary metadata.

hue removed

Two workers failed their health check.

Secondary description.

Tertiary metadata.

A review heuristic, not a conformance test — and not the same thing as the contrast requirement. The alert keeps its border, its weight and its position; the three ink tiers keep their separation. Nothing here needed hue to be understood.

Figure 

What the palette does not get to decide

Under prefers-contrast: more the core collapses --text-muted and --text-subtle to --text and thickens borders and focus. No theme can defeat that, by contract rather than by accident.

/* core/theme.css — the repeated attribute intentionally outranks
   single-attribute preset palettes, including a preset stylesheet
   loaded after the core. */
@media (prefers-contrast: more) {
  :root,
  [data-theme][data-theme] {
    --border-width: 2px;
    --focus-outline-offset: 3px;
    --focus-ring-width: 4px;
    --text-muted: var(--text);
    --text-subtle: var(--text);
  }
}

forced-colors: active is the other floor: the browser remaps colour, background and border to system colours, and the core supplies the handful of overrides the browser cannot infer — focus outline, selected and disabled states, the backdrop, and removing decorative shadows.

Figure 

Two rungs that are missing on purpose

Both are common in published token sets. Neither is here yet, and the reason is the same in both cases.

Absent What is there instead What would justify adding it
--border-strong One --border, plus the component's own rim where it needs a heavier one. Applications repeatedly reaching for a color-mix() on --border, or misusing --text-subtle to get a firmer line.
A third elevation --shadow for resting, --shadow-popout for detached surfaces. A recurring hero or floating treatment that every application ends up re-deriving the same way.

A small token set is a feature — every rung is one more thing a theme author has to get right, in light and in dark, for every island. The bar for adding one is evidence from real compositions, not symmetry with someone else's list.