Core
Primary is the anchor
Settings

How the five derived colors are placed around the anchor.

Theme builder

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-primary
bg-secondary
bg-accent
bg-light
bg-dark
bg-danger

Purpose

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

Primary--color-primary

CTAs, links, focus rings. White ink on top.

Secondary--color-secondary

Tier-two fills. Not a body-text color — see the matrix.

Accent--color-accent

Highlights and informational grounds. Dark ink only, never white.

Neutrals

Two values carry the page: the ground, and the ink on it.

Light--color-light

Page ground. Cards sit on top of it in plain bg-white.

Dark--color-dark

Body 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

Danger--color-danger

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

UseClassContrast on bg-light
Body text, headingstext-dark18.28:1 — AAA
Secondary text, labelstext-dark/707.82:1 — AAA
Meta, captions, timestampstext-dark/554.52:1 — AA floor
Disabled texttext-dark/402.77:1 — non-informational only
Strong border, emphasis ruleborder-dark/22non-text
Hairline, divider, input borderborder-dark/12non-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:

SurfaceClass
Page groundbg-light
Card, panel, field, popover, modalbg-white
Inverse bandbg-dark
Modal scrimbg-black/50
ShadowNot 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.

Get started
Primary CTAtext-white on bg-primary8.71:1AA body text
Featured
Tier-two fill — passes, but with little margintext-white on bg-secondary4.96:1AA body text
New
Accent highlight — dark ink is mandatorytext-dark on bg-accent16.77:1AA body text
Delete
Destructive actiontext-white on bg-danger7.57:1AA body text

Chromatics as text on the page ground

This is the rule the old token system never stated, and it is where most mistakes happen:

Read the guide
Links and emphasistext-primary on bg-light7.70:1AA body text
Headline only
Below AA for body copytext-secondary on bg-light4.38:1Large text only
Do not use
Invisible on light. Fill or on-dark onlytext-accent on bg-light1.09:1Never as text

secondary (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

Restbg-primary
Hoverbg-primary/90
Activebg-primary/80

Destructive action

Restbg-danger
Hoverbg-danger/90
Activebg-danger/80
html
<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:

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

html
<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:

html
<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:

html
<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:

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

PairPreviewForegroundBackgroundRatioAAAAA
Body textAatext-darkbg-light
Secondary textAatext-dark/70bg-light
Meta textAatext-dark/55bg-light
Disabled textAatext-dark/40bg-light
Link textAatext-primarybg-light
Danger textAatext-dangerbg-light
Secondary as textAatext-secondarybg-light
Accent as textAatext-accentbg-light
Ink on primaryAatext-whitebg-primary
Ink on secondaryAatext-whitebg-secondary
Ink on accentAatext-darkbg-accent
Ink on dangerAatext-whitebg-danger
Ink on inverseAatext-whitebg-dark
Muted on inverseAatext-white/58bg-dark
Danger on subtleAatext-dangerbg-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:

css
: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

  1. Six colors. If you need a seventh, it is a brand decision, not a CSS one.
  2. Neutrals come from dark at one of the five ladder steps. Nothing else.
  3. bg-white for cards and fields; bg-light for the page.
  4. Never white ink on accent.
  5. accent is never text on a light ground. secondary is large text only — and it is not used as text here.
  6. Hover is /90, active is /80. Ghost hover is /10.
  7. Gradients are written where they are used.
  8. Status never communicates by color alone.