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
Queuedprimary
--primary
fill
ink
wash
Queuedsecondary
--secondary
fill
ink
wash
Queuedbrand
--brand
fill
ink
wash
Queuedsuccess
--success
fill
ink
wash
Queuedwarn
--warn
fill
ink
wash
Queueddestructive
--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.
- WCAG ratio, tertiary ink
The dark theme measures nearly twice the light one.
- Lc, the same two inks
Polarity-aware, so it ranks them the other way.
- Floors held in CI
Both themes, every release.
Light
Dark
Light
Dark
Read
Marks
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.
Six weeks after the migration the queue drained on its own, which nobody had predicted and nobody could explain.
Light
steps of 19 and 20 Lc
Body
ratio 15.25:1 · Lc 98.2Secondary
ratio 6.63:1 · Lc 79.6Tertiary
ratio 3.44:1 · Lc 59.5
Dark
steps of 18 and 30 Lc
Body
ratio 15.91:1 · Lc 95.2Secondary
ratio 12.10:1 · Lc 77.3Tertiary
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 groundLc 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 itThe 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.