Color
Eight flat brand colors, Tailwind's alpha modifier for everything between them, and a measured table of which combinations are allowed for what.
bg-primarybg-secondarybg-accentbg-lightbg-darkbg-dangerPurpose
There are six colors. Everything between them — secondary text, hairlines,
subtle alert grounds, hover states — is one of those six at reduced opacity,
written with Tailwind’s / modifier.
A card is bg-white. Body text is text-dark. A hairline is border-dark/12.
Which colors may sit together, and for what, is the pairing rules below — measured, not asserted.
The six colors
Defined in src/styles/tokens.css
inside @theme, so Tailwind generates bg-*, text-*, border-*, outline-*,
ring-*, from-*/to-* and the /<alpha> modifier for each. No custom utility
classes are defined anywhere.
Chromatics
--color-primaryCTAs, links, focus rings. White ink on top.
--color-secondaryTier-two fills. Not a body-text color — see the matrix.
--color-accentHighlights and informational grounds. Dark ink only, never white.
Neutrals
Two values carry the page: the ground, and the ink on it.
--color-lightPage ground. Cards sit on top of it in plain bg-white.
--color-darkBody text, icons, and inverse band fills.
Black and white are Tailwind’s. bg-white for cards and fields, bg-black/50
for scrims. Shadows are not a color decision here at all — they are a convention,
not a token.
Status
--color-dangerErrors and destructive actions. White ink on top.
Danger is the only status color. There is no success, no warning, and no
info. Confirmation reads as ordinary content; caution and informational callouts
use accent. A palette earns a hue when a project actually needs it — six
values is already six decisions per project.
Danger is flat like the chromatics — no 50–950 ramp. Its subtle alert ground is the
same hue at /10.
The ink ladder
One neutral, six steps. This replaces every text-*, border-*, and surface-*
role the system used to define.
| Use | Class | Contrast on bg-light |
|---|---|---|
| Body text, headings | text-dark | 18.28:1 — AAA |
| Secondary text, labels | text-dark/70 | 7.82:1 — AAA |
| Meta, captions, timestamps | text-dark/55 | 4.52:1 — AA floor |
| Disabled text | text-dark/40 | 2.77:1 — non-informational only |
| Strong border, emphasis rule | border-dark/22 | non-text |
| Hairline, divider, input border | border-dark/12 | non-text |
Do not invent steps between these. Six is the whole vocabulary — /65 and
/45 are not options. That single rule is what keeps opacity-based neutrals from
drifting into an unmanaged gradient of near-identical greys.
Surfaces need no tokens at all:
| Surface | Class |
|---|---|
| Page ground | bg-light |
| Card, panel, field, popover, modal | bg-white |
| Inverse band | bg-dark |
| Modal scrim | bg-black/50 |
| Shadow | Not a color choice — a convention, not a token |
On an inverse band the ladder flips: text-white, text-white/58 for muted,
border-white/28 and border-white/44 for edges, and bg-white/8 for a card
sitting on the band.
Text uses text-white; muted hints usetext-white/58. On an inverse band the ink ladder inverts — white at alpha replaces dark at alpha.
Placeholder or secondary line on inverse
Use Button with appearance="inverse" or a parent withdata-on-inverse on the page shell.
Which combinations are allowed
Ink on solid fills
There is exactly one correct ink per fill, so this is a lookup, not a decision. The one that bites: white never goes on accent.
text-white on bg-primary8.71:1AA body texttext-white on bg-secondary4.96:1AA body texttext-dark on bg-accent16.77:1AA body texttext-white on bg-danger7.57:1AA body textChromatics as text on the page ground
This is the rule the old token system never stated, and it is where most mistakes happen:
text-primary on bg-light7.70:1AA body texttext-secondary on bg-light4.38:1Large text onlytext-accent on bg-light1.09:1Never as textsecondary (4.38:1) clears the 3:1 bar for large text — 24px regular or
18.66px bold — but fails it for body copy. It is not used as text anywhere in this
system: prefer it as an icon, border, or fill, and let the message text stay
text-dark.
accent is not a text color on a light ground at any size. It is a fill.
States
Hover and active are the same color at reduced opacity. No -hover or -active
values exist.
Primary action
bg-primarybg-primary/90bg-primary/80Destructive action
bg-dangerbg-danger/90bg-danger/80<button class="bg-primary hover:bg-primary/90
active:bg-primary/80 text-white">On a light ground, /90 reads as softening rather than deepening — the fill
composites toward the page, so hover is slightly lighter. Contrast holds
(8.71:1 → 7.25:1). When a specific control genuinely needs to darken instead,
hover:brightness-90 does it with no new token and no new utility. Do not
reintroduce a second color value for it.
Ghost and outline controls tint instead of fill: hover:bg-primary/10.
Focus is always the primary hue, or white on an inverse band:
<a class="focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-primary">Status and zoning grounds
A subtle alert background is the signal hue at /10, carrying text-dark at
15:1 or better so message text stays readable.
<div class="bg-danger/10 text-dark">Something went wrong</div>
<div class="bg-accent/10 text-dark">Worth knowing</div>Accent is invisible as text on a light ground, so informational copy stays
dark — the ground carries the meaning, not the text color.
Zoning without borders
The more common use of grounds is structural, not semantic. Adjacent sections alternate between two near-tones and the seam reads as a boundary on its own:
<section class="bg-light">…</section>
<section class="bg-white">…</section>
<section class="bg-light">…</section>A card on a near-tone ground separates without a hairline, which is why borders are off by default in this system. Reach for a border only when two zones must sit on the same ground.
Inverted, the card is a white overlay — not a second dark ground. The
tempting mirror is bg-dark against bg-black, and it does not work: dark is
also the body text, so the ink ladder pins it near the floor and the pair
measures 1.02:1. On a real screen it is one flat rectangle. Use the white the
light side is already made of, at an alpha step:
<section class="bg-dark">
<article class="bg-white/8 p-6">…</article>
</section>That measures 1.15:1, against 1.13:1 for a white card on bg-light — the
same separation, reached from the other end. It also costs no token and survives
a retheme, because it composites against whatever --brand-dark becomes. If
borders are on, the hairline flips too: border-white/12, never border-dark/12.
Alpha composites against whatever is behind it. A /10 tint reads slightly
lighter on bg-white than on bg-light. That is fine in practice; just
don’t stack two translucent surfaces and expect a predictable result.
Gradients
Gradients are not tokens. Write them at the use site with Tailwind’s gradient utilities, which already read the brand colors and the alpha modifier:
<div class="bg-linear-145 from-primary/15 via-light to-accent/20">The home page hero is exactly this, with bg-light underneath so the alpha stops
composite against the page ground. Reach for an arbitrary
bg-[linear-gradient(...)] value only when the stop utilities cannot express the
shape you need — and note that Tailwind splits an arbitrary value on commas, so a
nested color-mix() stop silently collapses to its first argument.
What is not fine is promoting a gradient to a shared token — there was exactly one, used in one place.
Over a gradient, keep body copy on a solid panel (bg-white) and let only large
headings sit directly on the wash.
Accessibility
Ratios below are computed in the browser from the live token values. Change any of
the six colors in tokens.css and this table re-measures on reload — including
the translucent steps, which are checked composited over their background rather
than at full strength.
| Pair | Preview | Foreground | Background | Ratio | AA | AAA |
|---|---|---|---|---|---|---|
| Body text | Aa | text-dark | bg-light | — | — | — |
| Secondary text | Aa | text-dark/70 | bg-light | — | — | — |
| Meta text | Aa | text-dark/55 | bg-light | — | — | — |
| Disabled text | Aa | text-dark/40 | bg-light | — | — | — |
| Link text | Aa | text-primary | bg-light | — | — | — |
| Danger text | Aa | text-danger | bg-light | — | — | — |
| Secondary as text | Aa | text-secondary | bg-light | — | — | — |
| Accent as text | Aa | text-accent | bg-light | — | — | — |
| Ink on primary | Aa | text-white | bg-primary | — | — | — |
| Ink on secondary | Aa | text-white | bg-secondary | — | — | — |
| Ink on accent | Aa | text-dark | bg-accent | — | — | — |
| Ink on danger | Aa | text-white | bg-danger | — | — | — |
| Ink on inverse | Aa | text-white | bg-dark | — | — | — |
| Muted on inverse | Aa | text-white/58 | bg-dark | — | — | — |
| Danger on subtle | Aa | text-danger | bg-danger/10 | — | — | — |
AA requires ≥ 4.5:1 for normal text, ≥ 3:1 for large text. AAA requires ≥ 7:1. Ratios are computed live from the active token values — swap the six --brand-* values in tokens.css and this table updates on reload. Translucent steps are measured composited over their background, which is what the browser actually paints.
Two standing rules that contrast alone does not cover:
- Never rely on color alone. Status always carries an icon or a text label.
- Focus rings need 3:1 against the adjacent surface, not against the page.
Retheming
Replace the eight --brand-* values in :root. Nothing derives from anything else, so
there is no chain to trace and no on-* value to keep in sync:
:root {
--brand-primary: oklch(0.432 0.156 27.07);
--brand-secondary: oklch(0.573 0.232 28.51);
--brand-accent: oklch(0.925 0.191 102.74);
--brand-light: oklch(0.958 0.006 43.33);
--brand-dark: oklch(0.095 0.034 27.07);
--brand-danger: oklch(0.46 0.20 22);
}@theme aliases each of these through var() — leave that block alone. The
indirection is what keeps text-dark/70 resolving to a live color-mix()
instead of a literal frozen at build time.
After swapping, reload this page and read the matrix above. The pairing rules are
stated for the current palette — a different primary can move secondary
above AA, or push danger below it. The ratios are the contract, not the
class names.
The theme builder does this for you and hands back a finished global.css.
Rules
- Six colors. If you need a seventh, it is a brand decision, not a CSS one.
- Neutrals come from
darkat one of the five ladder steps. Nothing else. bg-whitefor cards and fields;bg-lightfor the page.- Never white ink on
accent. accentis never text on a light ground.secondaryis large text only — and it is not used as text here.- Hover is
/90, active is/80. Ghost hover is/10. - Gradients are written where they are used.
- Status never communicates by color alone.