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 .

Tip: this page reads the same URL format as the generator. Append a share hash — for example documentation.html#e11d48+0d9488+7c3aed — and reload, and every table, ramp, and demo below documents that palette instead.

The system rests on four rules:

  1. One color space for everything. All math happens in OKLCH, a perceptually uniform space, so "equal steps" actually look equal.
  2. 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.
  3. Accessibility is enforced, not audited. Every text token is iteratively nudged until it meets its WCAG 2.1 target before it's ever emitted.
  4. 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-bg and --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:

FormatValueNotes
HexCanonical storage format; what the exports use.
RGB8-bit sRGB channels.
HSLFamiliar, but not perceptually uniform — used for display only, never for math.
OKLCHLightness % · 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):

StepLightness targetChroma multiplierTypical use
5097.5%× 0.12Tinted washes, ghost-hover fills
10095.0%× 0.25Selected states, secondary buttons
20090.5%× 0.45Hovered tints
30084.5%× 0.68Dark-mode links, decorative strokes
40075.5%× 0.90Glows, dark-mode borders
50066.0%× 1.00Chroma peak; dark-mode buttons
60057.5%× 0.98Light-mode buttons and links
70049.0%× 0.88Hover states, CTA gradient ends
80041.0%× 0.74Deep fills
90033.5%× 0.60Near-dark fills
95026.0%× 0.47Headings, 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.

TokenDerivation (light)Derivation (dark)
--{color}-mainStep 600, adjusted by the fill solverStep 500, adjusted by the fill solver
--{color}-hoverMain, lightness −5.5% (+5.5% if it carries dark text)Main, lightness +5% (−5% if dark text)
--{color}-textStep 600, nudged to ≥ 4.5:1 on the pageStep 300, nudged to ≥ 4.5:1 on the page
--{color}-selected-bgStep 100oklch(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:

  1. If white already reaches 4.5:1 on the fill → white text, fill unchanged.
  2. 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.
  3. 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.

TokenLight: L · chromaLight valueDark: L · chromaDark value
--background-page98.8% · ×0.5016.5% · ×1.00
--background-subtle97.1% · ×0.7019.0% · ×1.00
--background-elevated99.8% · ×0.2523.5% · ×1.10
--background-card99.3% · ×0.4020.5% · ×1.00
--background-input99.0% · ×0.5019.0% · ×1.00
--background-hover95.4% · ×0.9526.5% · ×1.20
--background-active92.7% · ×1.1530.0% · ×1.25
--background-inverse24.5% · ×1.3094.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.

TokenStart L (light / dark)WCAG targetChecked againstLightDark
--heading-colorBrand 950 / Brand 1007:1 (when adjusted)Page
--text-primary24% / 93%7:1 (AAA)Page
--text-secondary38% / 79%7:1 (AAA)Page
--text-muted47% / 70%4.5:1 (AA)Page
--text-subtle54.5% / 62%3:1 (AA large)Page
--text-disabled66.5% / 50%exempt (WCAG 1.4.3)
--text-linkBrand 600 / Brand 3004.5:1 (AA)Page
--text-link-hoverBrand 700 / Brand 2004.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.

TokenLight: LDark: LLightDark
--border-subtle92.4%27%
--border-default86.8%33%
--border-strong71.5%46%
--border-brandBrand 500Brand 400
--border-focusBrand 500Brand 400
--border-disabled91.6%26%
--divider94%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%.

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

TokenRecipe (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):

Primary shadow--shadow-brand
Secondary shadow--shadow-secondary
Tertiary shadow--shadow-tertiary
Primary shadow · large--shadow-brand-lg
Secondary shadow · large--shadow-secondary-lg
Tertiary shadow · large--shadow-tertiary-lg

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:

Primary glow--glow-brand
Secondary glow--glow-secondary
Tertiary glow--glow-tertiary

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: a backdrop 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 */
ThresholdNormal textLarge text (1.5rem+ / bold 1.17rem+)
AA4.5:13:1
AAA7:14.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-theme in localStorage. System mode watches prefers-color-scheme via matchMedia and live-updates when the OS switches. The resolved choice lands as data-theme on <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.