Color
How the five hues become a full light and dark palette, and the contrast gate that keeps every pair readable.
Last updated
Components never read a raw palette step. They read a hue and a role
(bg-primary, text-neutral-faded, text-on-critical), so a theme is a
token override, not a rebuild. The full role list is on
Design tokens; this page is about where the values come from.
Five inputs
scripts/hues.mjs holds one colour per hue, by Tailwind name or as OKLCH:
export default {
primary: 'blue-700', // a name is a Tailwind colour; 'blue' means 'blue-600'
critical: 'red-600',
warning: 'amber-400',
positive: 'green-700',
neutral: 'slate', // the grey family: slate, gray, zinc, neutral, stone, mauve, olive, mist, taupe
}
Accents accept any name from scripts/palettes.mjs with a step from 400 to
700, or an object { l, c, h } for a brand colour that is not in the palette.
The neutral entry tints every grey: page, borders, text, disabled states and
the hover and selected fills. A grey name takes that family’s chroma and hue;
chroma peaks at mid lightness and tapers to nothing at white and black. For a
custom tint pass { l: 0.94, c, h }, where c is the peak chroma and
c: 0 gives pure grey.
npm run build:tokens derives everything else and writes it between the
@generated markers in registry/tokens/theme.css.
What the generator derives
- Dark fill. Lightness inverts around the midpoint, weighted by chroma, and is clamped so a dark brand never turns black. Chroma drops a little.
- Faded tint. The fill at low alpha, so it composites over any surface.
- Hover steps. A small move in lightness. If that move would push the on-colour under AA, the step goes the other way.
- Foreground. The hue pulled toward the page’s text until it passes 4.5:1 on the page and on the faded tint.
- On-colour. White when white passes 4.5:1 on the fill, black otherwise.
warningis light, so its on-colour is black in both themes. - Neutral extras. Page, elevation steps, disabled set, inverted surface.
Show codeHide code
<Button>Primary</Button>
<Button color="critical">Critical</Button>
<Button color="positive" variant="faded">Positive</Button>
<Button color="warning">Warning</Button>
<Badge color="neutral">Neutral</Badge>Dark mode
Set data-theme="dark" on <html> or on any subtree. Both themes come out of
the same inputs, so a brand change updates both. See Dark mode.
Dark subtree
Show codeHide code
<div data-theme="dark" class="rounded-lg bg-page p-4">
<Alert color="primary" title="Dark subtree">Everything inside recolours.</Alert>
</div>The contrast gate
npm run check:contrast reads the real values from theme.css and measures
every pair the generator produces, in both themes:
- each
--foreground-{hue}on the page, onelevation-raised, and on its own faded tint; - each
--on-{hue}on the fill and on the hovered fill; --foreground-neutral-fadedon the page and on the faded neutral fill;--on-invertedon the inverted surface;- the focus ring on the page at 3:1;
- body text on every elevation.
Faded tints are alpha colours, so the gate composites them over the page in
gamma sRGB, the way a browser paints them, before measuring. It exits non-zero
on the first miss, and prepublishOnly runs it.
Overriding one token
Set the variable after the theme import:
[data-brand="larkspur"] {
--background-primary: oklch(0.45 0.18 300);
--background-primary-highlighted: oklch(0.4 0.18 300);
--foreground-primary: oklch(0.42 0.18 300);
--on-primary: white;
}
A hand override skips the generator, so run the gate afterwards. Overriding a fill without its on-colour is the usual way to break AA.
Do / Don’t
- Do change
scripts/hues.mjsand rebuild rather than editing generated lines. - Do keep
primarydark enough for white text; the gate will tell you. - Don’t reach for a Tailwind palette step (
bg-blue-600) in a component. - Don’t put
text-primaryonbg-primary; the on-colour exists for that.