Token Tint documentation
How every color is made
Token Tint turns three brand colors — a primary, a secondary, and a tertiary, all treated as equals — into two complete interface color systems. This page documents every derivation: the exact lightness ladders, chroma formulas, contrast targets, and opacity recipes behind each token, and how each one traces back to your brand colors.
All values on this page are live: they're computed by the same engine the generator uses, from the palette currently documented — primary , secondary , tertiary .
documentation.html#e11d48+0d9488+7c3aed — and reload, and every table, ramp, and demo below documents that palette instead.The system rests on four rules:
- One color space for everything. All math happens in OKLCH, a perceptually uniform space, so "equal steps" actually look equal.
- Everything traces to a brand color. Neutrals, surfaces, shadows — even the grays — keep the primary's hue at reduced chroma. Nothing is a hard-coded gray.
- Accessibility is enforced, not audited. Every text token is iteratively nudged until it meets its WCAG 2.1 target before it's ever emitted.
- Dark mode is designed, not inverted. The dark system is generated by its own rules: elevation gets lighter, chroma is muted, whites are softened.
Formats & naming
How tokens are written, emitted, and copied.
Every token is emitted as a CSS custom property in two blocks: :root carries the shared scales plus all light-mode values, and [data-theme="dark"] overrides the theme-dependent tokens. Because both blocks define the same names, switching the data-theme attribute on <html> re-themes the entire interface with no JavaScript involvement.
:root {
--brand-500: …; /* shared scales live here */
--background-page: …; /* light-mode values */
}
[data-theme="dark"] {
--background-page: …; /* dark-mode overrides */
}
Naming convention
--brand-50 … --brand-950,--secondary-*,--tertiary-*,--neutral-*— the four 11-step scales (shared across themes).--{color}-main / -hover / -text / -selected-bgand--text-on-{color}— the role tokens each brand color gets.--background-*,--text-*,--border-*,--button-*— semantic interface tokens, defined per theme.--success-* / --warning-* / --error-* / --info-*— semantic state colors, five parts each.--shadow-*,--glow-*,--overlay,--scrim,--backdrop-*,--gradient-*— effects.
Copy formats
Solid colors are stored internally as 6-digit hex and can be expressed in four formats. The same primary color in each:
| Format | Value | Notes |
|---|---|---|
| Hex | | Canonical storage format; what the exports use. |
| RGB | | 8-bit sRGB channels. |
| HSL | | Familiar, but not perceptually uniform — used for display only, never for math. |
| OKLCH | | Lightness % · chroma · hue°. The space all generation happens in. |
Effects (shadows, glows, overlays, gradients, blurs) are stored as complete CSS values and always copied verbatim — there's nothing to convert.
The OKLCH color space
Why the math doesn't happen in HSL.
OKLCH describes a color by three intuitive axes:
- L — lightness (0–100%). Perceptual: 60% genuinely looks halfway between 50% and 70%, regardless of hue. This is what makes shared "lightness ladders" possible.
- C — chroma (0 → ~0.37). Colorfulness. 0 is gray; sRGB can hold roughly up to 0.37 depending on hue and lightness.
- H — hue (0–360°). The angle around the color wheel. Rotating hue at constant L and C changes which color, not how light or vivid it is.
HSL's "lightness" is famously misleading — pure yellow and pure blue both claim 50% but differ wildly in perceived brightness. Because Token Tint builds scales, checks contrast direction, and rotates hues, it needs a space where those operations behave predictably. Colors are converted sRGB → OKLab → OKLCH for the math, then converted back for output. When a computed color falls outside the sRGB gamut, its chroma is reduced by binary search (lightness and hue are preserved) until it fits — so out-of-gamut requests desaturate rather than shift.
Your current colors in OKLCH terms: the primary sits at hue — this single angle is the anchor the neutrals, surfaces, shadows, and semantic hue-pulls all reference.
The three brand scales
Primary, secondary, and tertiary each get the same 11-step ladder.
Each scale is built from one shared lightness ladder paired with a chroma curve that peaks mid-scale (vivid colors can't survive at near-white or near-black lightness, so chroma tapers toward the ends). For each step, the generated color is oklch(Lstep, Cbrand × multstep, Hbrand):
| Step | Lightness target | Chroma multiplier | Typical use |
|---|---|---|---|
50 | 97.5% | × 0.12 | Tinted washes, ghost-hover fills |
100 | 95.0% | × 0.25 | Selected states, secondary buttons |
200 | 90.5% | × 0.45 | Hovered tints |
300 | 84.5% | × 0.68 | Dark-mode links, decorative strokes |
400 | 75.5% | × 0.90 | Glows, dark-mode borders |
500 | 66.0% | × 1.00 | Chroma peak; dark-mode buttons |
600 | 57.5% | × 0.98 | Light-mode buttons and links |
700 | 49.0% | × 0.88 | Hover states, CTA gradient ends |
800 | 41.0% | × 0.74 | Deep fills |
900 | 33.5% | × 0.60 | Near-dark fills |
950 | 26.0% | × 0.47 | Headings, darkest usable tone |
Anchoring
The scale never approximates your input. The generator finds the step (within 200–900) whose lightness target is closest to your color's actual lightness; if the difference is under 0.09 L, that step is replaced with your exact hex. Right now: the primary is anchored at Brand , the secondary at Secondary , and the tertiary at Tertiary .
Primary scale
Secondary scale
Tertiary scale
Role tokens & the fill solver
Every brand color carries the same five roles per theme.
| Token | Derivation (light) | Derivation (dark) |
|---|---|---|
--{color}-main | Step 600, adjusted by the fill solver | Step 500, adjusted by the fill solver |
--{color}-hover | Main, lightness −5.5% (+5.5% if it carries dark text) | Main, lightness +5% (−5% if dark text) |
--{color}-text | Step 600, nudged to ≥ 4.5:1 on the page | Step 300, nudged to ≥ 4.5:1 on the page |
--{color}-selected-bg | Step 100 | oklch(33% · C×0.4 · H), chroma clamped 0.012–0.08 |
--text-on-{color} | Chosen by the fill solver (below) | |
The fill solver
Filled controls (buttons, toggles, badges on brand color) get their text via a solver that prefers white text, because white-on-color reads better than dark-on-color at equal ratios:
- If white already reaches 4.5:1 on the fill → white text, fill unchanged.
- Otherwise, try darkening the fill until white passes. The darkened fill is accepted only if the drop is modest (≤ 0.16 L) and the result stays above 0.30 L — a navy that needs a nudge gets one; a bright yellow that would turn olive does not.
- Otherwise fall back to a near-black tinted ink (the neutral ramp at 16% L), lightening the fill if even that can't reach 4.5:1.
Live result for the current palette — all three pass ≥ 4.5:1 by construction:
Tinted neutrals
The "grays" that aren't gray.
Every neutral keeps the primary's hue at a whisper of chroma, computed as:
tint = clamp(0.004 + C_primary × 0.09, 0.004, 0.013)
For the current primary that's tint = . It's below the threshold where a surface reads as "colored," but enough that neutrals feel related to the brand rather than borrowed from another product. The neutral ladder uses its own lightness targets (98.5% → 19.5%) with the tint scaled ×1.2 on the darker half (≥ 400) and ×0.8 on the lighter half, since darker tones can carry a bit more chroma before it shows.
The neutral scale is the raw material for backgrounds, text, borders, dividers, disabled states, and the shadow ink — which is why the whole interface shifts subtly when you change the primary, even where no "brand color" is visible.
Backgrounds
Two surface systems, one hue.
Both surface sets are built from the primary hue at neutral-grade chroma. Light mode lives in a narrow near-white band where small lightness steps read as elevation and interaction; dark mode lives in a low band where elevation gets lighter — the opposite of light mode's shadow logic — because on dark surfaces "closer to the light source" means brighter, not shadowed. Chroma multipliers below scale the neutral tint (light) or dc = clamp(tint × 1.4, 0.006, 0.02) (dark), which is slightly raised so dark surfaces don't collapse to pure gray.
| Token | Light: L · chroma | Light value | Dark: L · chroma | Dark value |
|---|---|---|---|---|
--background-page | 98.8% · ×0.50 | | 16.5% · ×1.00 | |
--background-subtle | 97.1% · ×0.70 | | 19.0% · ×1.00 | |
--background-elevated | 99.8% · ×0.25 | | 23.5% · ×1.10 | |
--background-card | 99.3% · ×0.40 | | 20.5% · ×1.00 | |
--background-input | 99.0% · ×0.50 | | 19.0% · ×1.00 | |
--background-hover | 95.4% · ×0.95 | | 26.5% · ×1.20 | |
--background-active | 92.7% · ×1.15 | | 30.0% · ×1.25 | |
--background-inverse | 24.5% · ×1.30 | | 94.0% · ×0.50 | |
Notice the ordering logic: in light mode, elevation goes up in lightness (page 98.8% → elevated 99.8%) while interaction goes down (hover 95.4% → active 92.7%). In dark mode both go up — hover and active are brighter because dark interfaces communicate "pressed" with light, not shade. --background-inverse is the opposite theme's page tone, used for toasts and high-contrast chrome.
Text colors
Designed lightness first, then enforced contrast.
Text tokens start from the neutral ramp at a designed lightness, then run through the contrast enforcement loop (see Contrast math) against the surface they're specified for. If a token misses its target, its lightness moves in 1.4% steps — darker on light surfaces, lighter on dark ones, with chroma decaying 1.5% per step so extreme tones stay clean — until it passes.
| Token | Start L (light / dark) | WCAG target | Checked against | Light | Dark |
|---|---|---|---|---|---|
--heading-color | Brand 950 / Brand 100 | 7:1 (when adjusted) | Page | | |
--text-primary | 24% / 93% | 7:1 (AAA) | Page | | |
--text-secondary | 38% / 79% | 7:1 (AAA) | Page | | |
--text-muted | 47% / 70% | 4.5:1 (AA) | Page | | |
--text-subtle | 54.5% / 62% | 3:1 (AA large) | Page | | |
--text-disabled | 66.5% / 50% | exempt (WCAG 1.4.3) | — | | |
--text-link | Brand 600 / Brand 300 | 4.5:1 (AA) | Page | | |
--text-link-hover | Brand 700 / Brand 200 | 4.5:1 (AA) | Page | | |
The heading color
Headings default to the deepest usable step of the primary scale — Brand 950 in light mode, flipped to Brand 100 in dark mode (nudged to 7:1 if needed). The generator's "Headings use primary color" toggle swaps in the exact primary instead; in dark mode that value is lightened until it clears 4.5:1, since most brand colors are too dark to sit on a dark page unmodified.
Borders
Neutral tones between the surfaces and the text.
| Token | Light: L | Dark: L | Light | Dark |
|---|---|---|---|---|
--border-subtle | 92.4% | 27% | | |
--border-default | 86.8% | 33% | | |
--border-strong | 71.5% | 46% | | |
--border-brand | Brand 500 | Brand 400 | | |
--border-focus | Brand 500 | Brand 400 | | |
--border-disabled | 91.6% | 26% | | |
--divider | 94% | 25% | | |
The three functional weights step down in lightness so hierarchy is available without color: subtle for card edges, default for inputs, strong for emphasis. Brand and focus borders come straight from the primary scale — one step lighter in dark mode so they stay visible on dark surfaces.
Interactive colors
Buttons, links, focus, selection, toggles.
- Primary button — Brand 600 (light) / Brand 500 (dark), passed through the fill solver. Hover shifts lightness ∓5.5%, active ∓10.5% with chroma ×0.97 (direction depends on whether the button carries white or dark text). Currently
with text. - Secondary button — Brand 100 surface with Brand 200 hover; its text is Brand 800 nudged to 4.5:1 on that surface. Dark mode uses a brand-tinted low-lightness surface (
oklch(30% · C×0.35 · H)) with Brand 200 text. - Ghost hover — Brand 50 (light) /
oklch(26% · C×0.22 · H)(dark). - Focus ring — Brand 500 at 35% opacity (light), Brand 400 at 40% (dark):
. Paired with--shadow-focus, a 0.1875rem spread of the same color. - Selected state — Brand 100 surface + Brand 900 text (light); tinted dark surface + Brand 100 text (dark). Both text values contrast-enforced at 4.5:1.
- Toggles — on-state uses the solved primary button fill; off-state is the neutral ramp at 80% L (light) / 42% L (dark).
Semantic colors
Recognizable states, pulled toward your brand.
Success, warning, error, and info start from fixed, universally recognizable hues, then each is pulled 14% of the way toward the primary hue (shortest path around the wheel) and its chroma is scaled to the brand's intensity:
hue = blend(base_hue → H_primary, 14%) /* base hues: success 152° · warning 84° · error 27° · info 245° */
chroma = base_c × (0.72 + 0.28 × min(1, C_primary / 0.15)) /* base_c: error 0.155 · warning 0.125 · others 0.115 */
Each state gets five parts per theme. Light mode: background at 95.5% L (chroma ×0.28), border at 85.5% (×0.55), main at 58.5% (warning: 74%, because yellows only read "yellow" when light), text starting at 42% and enforced to 4.5:1 on the state's own background, and a strong/hover variant 7.5% darker than main. Dark mode: background 23.5% (×0.32), border 38%, main 72% (×0.85), text starting at 84% enforced to 4.5:1, strong 62%.
| State | Background | Border | Main | Text | Strong |
|---|---|---|---|---|---|
| Success | | | | | |
| Warning | | | | | |
| Error | | | | | |
| Info | | | | | |
(Light-mode values shown; the dark set follows the recipe above and appears in the token reference.) The 14% pull is deliberately small — enough that an error red next to a warm-red brand feels native, never enough to make it ambiguous.
Shadows & glows
Even the shadows carry the brand hue.
Elevation shadows
Ordinary elevation shadows never use pure black in light mode. Their ink is the shadow ink — the primary hue at 25% lightness with 1.6× the neutral tint (currently ) — used at low opacities, so shadows cool or warm with your brand instead of graying it out:
| Token | Recipe (light mode) |
|---|---|
--shadow-xs | |
--shadow-sm | |
--shadow-md | |
--shadow-lg | |
--shadow-xl | |
--shadow-inset | |
The pattern: opacity rises with size (5% → 30%), and from md up each shadow is two layers — a tight contact shadow plus a large soft ambient layer with a negative spread, which is what keeps big shadows from looking like smudges. In dark mode the same tokens switch to pure black at much higher opacities (30–75%), because tinted ink is invisible on dark surfaces and dark UIs need stronger separation.
Brand-tinted shadows — all three colors
Each brand color gets a colored shadow pair, built from its solved fill at 38% opacity (45% for the large size; 50–60% in dark mode, where colored shadows double as ambient light):
Glows
Glows are a two-layer structure built from each color's 400 step (the chroma-rich, light-enough stop that blooms well): a hairline 0.0625rem ring at 30% opacity that defines the edge, plus a 1.75rem soft bloom at 50%. Dark mode intensifies both (35% / 55%) and widens the bloom to 2rem:
Focus ring
--shadow-focus is a 0.1875rem zero-blur spread of the focus-ring color (Brand 500 @ 35% light / Brand 400 @ 40% dark): . It's paired with a solid outline so focus never depends on a translucent color alone.
Overlays & glass
Dimming, scrims, and frosted surfaces.
--overlay— modal dimming. Light mode uses the neutral-950 tone at 50% (); dark mode uses black at 60%.--scrim— a transparent-to-dark gradient for text sitting on imagery:.--backdrop-blur+--backdrop-tint— the glassmorphism pair: abackdrop filter combined with the elevated surface at 65% opacity (60% dark), so "glass" panels frost whatever sits behind them while staying in the brand's neutral family.
Gradients
Twelve gradients, every stop from a generated scale.
No gradient introduces a new color: every stop is a step from the brand scales or a surface token, so gradients recolor in lockstep with everything else. Single-hue gradients run 400 → 600 (135°); CTA variants deepen to 500 → 700; the mix and trio gradients travel across the brand hues; radial and conic forms reuse the same stops in different geometry.
Contrast math
The WCAG 2.1 formula behind every check.
Each color's relative luminance is computed from linearized sRGB channels (0.2126 R + 0.7152 G + 0.0722 B), and the ratio between two colors is:
ratio = (L_lighter + 0.05) / (L_darker + 0.05) /* 1:1 … 21:1 */
| Threshold | Normal text | Large text (1.5rem+ / bold 1.17rem+) |
|---|---|---|
| AA | 4.5:1 | 3:1 |
| AAA | 7:1 | 4.5:1 |
Enforcement: when a token misses its target, its OKLCH lightness moves in 0.014 steps — away from its background's luminance — with chroma decaying ×0.985 per step, for up to 70 iterations; if no passing tone exists on that path, it falls to pure black or white, whichever wins. Fills use the reverse function, moving the background's lightness away from a fixed text color. Live example: primary text on the page background currently measures (target 7:1).
Theming & sharing
How state travels.
- Theme — Light / Dark / System, stored as
palette-pass-themein localStorage. System mode watchesprefers-color-schemeviamatchMediaand live-updates when the OS switches. The resolved choice lands asdata-themeon<html>. - Colors — persisted per file (
palette-pass-new-brand / -secondary / -tertiary) and mirrored into the URL hash as#pppppp+ssssss+tttttt. A shared link outranks stored colors; two-color links from older versions still parse (the tertiary falls back to its default). - Exports — the CSS export is byte-identical to the style block powering the page; the JSON export nests the scales, both theme sets, gradients, and the share hash.
Token reference
Every custom property in the system, with its current value in both themes. Generated live by the same engine as the tool.