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.
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.
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.
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.
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
The card is #FFFFFF. Against the tinted canvas it reads cooler than it is.
sunset · warm card on a warm canvas
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.
.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
sunset · dark
/* 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.
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%
rim resolved to the fill
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.
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
Resting elevation.
sunset · shadow ink
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.
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
sunset · light
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.
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.
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
sunset · light
--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.
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
Healthyocean · dark
Healthysunset · light
Healthysunset · 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.
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.
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
€24/mo
Everything in Starter, plus shared workspaces and audit history.
Billed annually. Cancel anytime.
ocean · dark
€24/mo
Everything in Starter, plus shared workspaces and audit history.
Billed annually. Cancel anytime.
sunset · light
€24/mo
Everything in Starter, plus shared workspaces and audit history.
Billed annually. Cancel anytime.
sunset · dark
€24/mo
Everything in Starter, plus shared workspaces and audit history.
Billed annually. Cancel anytime.
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
ocean · dark
sunset · light
sunset · dark
Chapter six
Notes
Two review habits, and two rungs the token set deliberately does not have.
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
Secondary description.
Tertiary metadata.
hue removed
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.
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.
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.