foundations
Star
Reference

Tokens and theming

The package defines structural roles and leaves brand colours to your app. Every swatch below reads its colour through a variable, so the dark switch in the header re-points all of them at once.

Structural roles

tokens.css names the roles. theme.css assigns them the house latte and espresso palette. Swapping the palette leaves every call site untouched.

  • --background

  • --foreground

  • Aa

    --card

    Aa is --card-foreground

  • Aa

    --muted

    Aa is --foreground

  • --muted-foreground

  • --subtle-foreground

  • Aa

    --primary

    Aa is --primary-foreground

    --primary-ink

  • Aa

    --secondary

    Aa is --secondary-foreground

    --secondary-ink

  • Aa

    --accent

    Aa is --accent-foreground

  • --border

  • --input

  • --ring

Status

Three cuts each, and no exceptions left: the fill is a mark at 3:1, the ink is words on the page at 4.5:1, and the -foreground is the label printed on the fill at 4.5:1 against it. warn and success gained that third cut when a filled status button turned out to be a real thing rendering white on amber at 2.44:1. danger ships as an ink only.

  • Aa

    --success

    Aa is --success-foreground

    --success-ink

  • Aa

    --warn

    Aa is --warn-foreground

    --warn-ink

  • --info

    --info-ink

  • Aa

    --destructive

    Aa is --destructive-foreground

The seven tones

Buttons, badges, callouts, links and tab markers all read colour off one list. A tone earns its place by having a token of its own: seven tones, seven tokens, one for one. That rule is what keeps a design system from growing forty names for six colours.

  • muted

    --muted

    • fill

    • ink

    • wash

    Queued
  • primary

    --primary

    • fill

    • ink

    • wash

    Queued
  • secondary

    --secondary

    • fill

    • ink

    • wash

    Queued
  • brand

    --brand

    • fill

    • ink

    • wash

    Queued
  • success

    --success

    • fill

    • ink

    • wash

    Queued
  • warn

    --warn

    • fill

    • ink

    • wash

    Queued
  • destructive

    --destructive

    • fill

    • ink

    • wash

    Queued

Three cuts per tone. A fill is a mark and clears 3:1; an ink is read and clears 4.5:1.

brand belongs to your project, so it ships undefined and falls back to --primary. An app that has yet to pick an identity colour gets its principal one.

tone over raw tailwind classes

A tone is a prop. Passing one sets --tone-fill, --tone-ink and --tone-hue on the element. A solid button is then bg-(--tone-fill) text-(color:--tone-ink) whatever the tone is. Five components take the prop: Button, Badge, Callout, TypographyLink and TabsList.

<Button tone="destructive">Delete workspace</Button>
<Button tone="destructive" variant="soft">Delete workspace</Button>
<Badge tone="success" variant="soft">Deployed</Badge>
<Callout tone="warn" title="Rate limited">Retry in 30s.</Callout>

To tone something of your own, spend the same variables directly. toneClass declares them on any element:

<div className={cn(toneClass("brand"), "bg-(--tone-wash)")}>
  Your own surface, painted from the same seven.
</div>

Reach for one of these and you get a type error at the call site. The tone to use instead:

  • tone="neutral"

    tone="muted"

    The package says muted everywhere: --muted-foreground, TypographyMuted. Same register, one name.

  • tone="accent"

    tone="primary"

    --accent is the hover tint --primary casts. tone="primary" with variant="soft" is the colour you were after.

  • tone="info"

    tone="secondary"

    --info is a status token, spent on messages rather than controls. For a quiet coloured mark, use secondary.

Editorial inks

Defined in theme.css alone. Skip that import and the marker tones, the secondary link colour and the accordion keyframes resolve to nothing, silently. Two cuts each, the same shape as the status hues above: the fill is a mark, the ink is the same hue as words. The suffix is -ink; these eight shipped under -foreground until the rename, and the old spelling still resolves, so the lint rules are what retire it.

  • --terracotta

    --terracotta-ink

  • --ochre

    --ochre-ink

  • --moss

    --moss-ink

  • --fern

    --fern-ink

  • --sage

    --sage-ink

  • --stone

    --stone-ink

  • --fig

    --fig-ink

  • --cocoa

    --cocoa-ink

All eight are registered as Tailwind colours, so bg-fern and text-fern-ink work anywhere. Four of them, ochre, terracotta, sage and fig, double as marker highlight tones. The palette carries emphasis and identity, which is why it stops short of a warn, info or destructive hue.

Emphasis, in prose

A TypographyHighlight component paints a marker swipe behind a run of text. The four earth tones (ochre, terracotta, sage, fig) are the only hues that can hold at text weight in both themes, so these are the choices along with primary and success. The seed (optional) changes the wobble and the grain of the swipe.

Kind, as a mark

The four the marker turns down, spent as utilities. Identity rather than status: they say what a thing is.

  • Infrastructure
  • Databases
  • Tooling
  • Field notes

bg-moss paints the dot and text-moss-ink the words. The fill is a mark at 3:1 and the ink is read at 4.5:1, so labels sit beside a fill here instead of on it.

Two measures

WCAG is a ratio and APCA is a signed lightness contrast. The first gives us a pass/fail, and the second gives us a number to tune the ramp.

3.44:1

Light

6.75:1

Dark

WCAG ratio, tertiary ink

The dark theme measures nearly twice the light one.

59.5

Light

47.8

Dark

Lc, the same two inks

Polarity-aware, so it ranks them the other way.

4.5:1

Read

3:1

Marks

Floors held in CI

Both themes, every release.

A WCAG ratio treats both directions the same, but in practice a dark theme tuned to match its light theme’s numbers can still feel harder to read. A 4.5:1 ratio of black on white is crisp and easy to read; white on black is often thinner and more tiring. APCA lightness contrast uses two formulas, so it gives separate answers for light text on dark backgrounds and dark text on light backgrounds.

Six weeks after the migration the queue drained on its own, which nobody had predicted and nobody could explain.

Lightratio 4.87:1 · Lc 70.9

Six weeks after the migration the queue drained on its own, which nobody had predicted and nobody could explain.

Darkratio 4.79:1 · Lc 34.6

Light

steps of 19 and 20 Lc

  • Body

    ratio 15.25:1 · Lc 98.2
  • Secondary

    ratio 6.63:1 · Lc 79.6
  • Tertiary

    ratio 3.44:1 · Lc 59.5

Dark

steps of 18 and 30 Lc

  • Body

    ratio 15.91:1 · Lc 95.2
  • Secondary

    ratio 12.10:1 · Lc 77.3
  • Tertiary

    ratio 6.75:1 · Lc 47.8

The difference is easy to see. Both samples clear WCAG AA at 4.5:1. The two columns below show this site’s own contrast ramp from the active stylesheet. In the light theme, the ladder moves by 19 and then 20 Lc, which gives the even progression you wanted. In the dark theme, it moves by 18 and then 30, so its tertiary ink lands at 47.8 Lc compared with 59.5 in the light theme.
That comes out to 6.75:1 versus 3.44:1. On raw contrast ratio, the dark step looks safer, even though the WCAG numbers are much closer.

What Lc actually computes

Lc is the APCA lightness contrast score that WCAG 3 is based on. It starts with screen luminance measured against a perceptual curve, with a soft clamp near full-black where the model stops matching human perception. What matters here is that there are two different exponent pairs, one for each polarity. That asymmetry is why APCA works better than a single ratio when you are tuning a theme: a ratio uses one formula and gives one answer regardless of direction.

const s = yBackground > yText
  ? (yBackground ** 0.56 - yText ** 0.57) * 1.14   // dark text, light ground
  : (yBackground ** 0.65 - yText ** 0.62) * 1.14;  // light text, dark ground

Lc is unbounded and signed, and lc returns the absolute value. The practical question at a call site is whether the text is legible, and the sign only tells you which direction the contrast is running.

Which one to reach for

Ratios for compliance, Lc for the ramp. An audit asks for AA, which is what checkLegibility and checkSignals enforce: 4.5:1 for anything read, 3:1 for a mark. Whether your secondary ink feels noticeably quieter than your primary, and whether it stayed that way in dark mode, is an Lc question.

import { contrast, lc, parseColor, resolveTokens }
  from "@supertype.ai/foundations/contrast";

const tokens = resolveTokens(css, "dark");
const ink = parseColor(tokens["--muted-foreground"])!;
const page = parseColor(tokens["--background"])!;

contrast(ink, page);  // 4.79  — passes AA, says nothing about the ramp
lc(ink, page);        // 34.6  — the number that would have caught it

The floors are yours to set, in your own suite. Legibility is contrast times size, and a dense product spending 13px wants more headroom than an essay set at 18px. The package gives you that measurement both ways round, from the stylesheet you ship.

Both run against the cascade, not the file

resolveTokens scores selectors before it answers, so a .dark block that ties with a later :root is measured the way a browser would paint it. Read the two blocks separately and you measure an intention, which is how a theme once tested at a healthy 15.7:1 while the page rendered white on white.

Repainting

Override the raw variables after the imports, and leave the utility classes alone. The package owns its classnames, so patching them costs you the single point of control.

:root  { --primary: hsl(24 60% 42%); }
.dark  { --primary: hsl(24 70% 62%); }

The dark variant is bound to a class, not the OS

tokens.css declares @custom-variant dark against .dark. Drop that import and Tailwind v4 falls back to prefers-color-scheme, leaving your toggle inert.

The .editorial classname

Hands the heading role to the serif, drops the heading weight to 400, and retunes the ladder. Heading sizes are a ratio to the body text beneath them, and the two surfaces set body differently: 13px in the product, 18px on .editorial.

<div className="editorial">…</div>   {/* or on <html> for an editorial site */}

The switch in the header applies it to <html>. Turn it on and revisit the typography page: the heading sizes move, the body size stays put.