Adaptive Contrast: Automatically adjusting foreground to background
Any time the background changes color — dark mode, a tinted surface, a component placed over an image, a brand color — an immediate question arises: what text color is readable enough? This question has a mathematically precise answer, and it can be fully automated.
Drag the slider to change the background — foreground and contrast ratio adjust automatically
Heading — primary text
Secondary body — muted paragraph text
Caption · tertiary · meta information
| Element | Token | Ratio | WCAG |
|---|
Design system gray scale
Contrast ratio — the number to know
WCAG (Web Content Accessibility Guidelines) defines contrast ratio on a scale from 1:1 (no contrast) to 21:1 (black on white). The specific standards:
| Level | Minimum ratio | Applies to |
|---|---|---|
| AA (required) | 4.5:1 | Normal text (below 18pt) |
| AA Large | 3:1 | Large text (≥ 18pt regular or ≥ 14pt bold) |
| AAA (enhanced) | 7:1 | Normal text — the highest accessibility level |
4.5:1 is the minimum threshold for most use cases. Below this threshold, users with low vision, those reading screens in bright light, or those on low-quality displays will struggle.
How it’s computed — relative luminance
Contrast ratio is not computed directly from RGB values. It’s computed from relative luminance — a measure of a color’s brightness as the human eye perceives it, not a plain arithmetic value.
The WCAG formula:
luminance = 0.2126×R + 0.7152×G + 0.0722×B
Here R, G, B have already been linearized (sRGB gamma removed). The coefficients 0.2126/0.7152/0.0722 reflect the fact that the human eye is most sensitive to green and least sensitive to blue.
Once you have the luminance of the background (L₁) and foreground (L₂):
contrast ratio = (max(L₁,L₂) + 0.05) / (min(L₁,L₂) + 0.05)
The crossover point: When the background luminance is around 0.179 (equivalent to gray-60 in this design system), white and black text yield the same contrast ratio. Darker than that → use white. Lighter → use black.
Why a single rule “light = dark text, dark = light text” isn’t enough
That simple rule works well for two extreme colors (pure white / pure black). But in practice a design system has many surface levels: gray-0, gray-20, gray-50, gray-70… each needing a different foreground color.
On top of that, a brand color or a user-chosen color (avatar background, label color, theme color) isn’t part of the existing palette. Without computation, the text color ends up hardcoded and prone to contrast failures.
Design token approach — how this design system works
The semantic token system in this design system already solves the problem by pre-pairing background and foreground:
--bg-neutral-primary(gray-0) →--text-neutral-primary(gray-999): contrast 17.7:1 ✓--bg-neutral-secondary(gray-10) →--text-neutral-primary(gray-999): contrast 14.2:1 ✓--bg-neutral-tertiary(gray-20) →--text-neutral-primary(gray-999): contrast 11.3:1 ✓
Each pair has been tested and is guaranteed to pass WCAG AAA. When a component uses only tokens, contrast failures never occur — because the pair was already validated on the design side.
The problem arises when: the background is a custom color (brand accent, dynamic theme, user avatar color, background image). Here the tokens don’t cover it and runtime computation is needed.
Three approaches to automation
Approach 1 — Token pairs (current)
Every surface is predefined together with its foreground. A component takes a variant prop and applies the correct token pair.
Pros: Zero runtime cost, no JS dependency, guaranteed correct per the design. Cons: Doesn’t cover colors outside the palette. Adding a new color requires updating tokens manually.
Approach 2 — CSS color-contrast() (future)
CSS has a proposal for a native function:
color: color-contrast(var(--surface-color) vs black, white);
The browser computes and picks the color with the most adequate contrast. No JS, no token pairs needed.
Current status: Still being standardized, with no stable browser support yet (as of 2025). A polyfill or fallback is required.
Approach 3 — JS luminance computation (most flexible)
Compute relative luminance at runtime, compare contrast against black and white, and pick the better foreground. This approach works for any color — brand, user-generated, dynamic.
This is how the demo above works. It can be extended to choose not just between black/white but from the entire token palette, for a more visually fitting foreground.
Choosing a foreground from the palette instead of just black/white
When the background is an accent color (e.g. blue-60), pure white foreground may pass WCAG but look “flat”. A more refined approach: instead of choosing between two extremes, pick from the existing palette — for example blue-10 on blue-80. Contrast is sufficient, and the color stays within the system.
The logic:
- Compute the background luminance
- For each color in the palette: compute the contrast ratio
- Pick the color that passes AA (4.5:1) and is closest in tone to the background (same hue family if possible)
This approach requires more tokens but produces a far better visual result than jumping straight to black/white.
Edge cases to watch for
Mid-tone trap: Colors around luminance 0.179 — like gray-50 to gray-60 — don’t have enough contrast against either black or white at the AAA level. You must accept AA (4.5:1) or avoid using them as a surface for important text.
Saturated colors: Pure red or pure blue have low luminance despite looking “bright”. Red (#FF0000) has a luminance of only 0.213 — close to the mid-tone threshold. Black text on red reaches ~4.0:1, which doesn’t pass AA for normal text.
Transparency: A color with opacity changes contrast based on the actual background — not the declared hex color. It can’t be computed correctly without resolving the final color after compositing.
Background images: There’s no single “one color” to compute against. The practical solution: add a scrim (a faint gradient overlay) between the image and the text to guarantee a minimum contrast, rather than trying to compute contrast from the image.
Not just primary text — the whole hierarchy needs re-evaluation
When the background changes, the first instinct is to check the primary text. But a real UI has at least four color layers stacked on the same background:
- Primary text — headings, main labels, important values
- Secondary text — body paragraphs, descriptions, sub-labels
- Tertiary / caption — meta, timestamps, minor notes
- Placeholder / disabled — hint text in inputs, disabled states
In a design system, these layers are usually implemented with decreasing opacity (primary 100% → disabled 22%). The problem: when the background isn’t white or black, the color the eye actually sees is the result of blending that opacity with the background — no longer the hex value you declared.
A common pattern: primary text passes AAA, secondary passes AA, tertiary starts entering the danger zone, and placeholder almost certainly fails — especially on mid-tone backgrounds. The demo above shows an audit table for all these layers when you drag the slider into the L 40–60% range.
Borders and dividers — the opacity trick and its limits
Borders are often implemented with rgba(0,0,0,0.1) on light backgrounds — which works well when the background is white or near-white. On a dark background, that same opacity produces a color nearly indistinguishable from the background, and the divider disappears.
A simple rule: border opacity needs to switch at the same time as text — from black-opacity to white-opacity when the background crosses the luminance threshold of 0.179.
More subtly: a divider between two sections isn’t about “contrast between divider and background” but about “enough for the eye to recognize there’s a boundary there”. This threshold is lower than for text — 1.5:1 to 2:1 is usually enough. But if you lower the opacity too far to be “subtle”, the divider can vanish entirely on mid-tones.
Shadow — reversing direction on dark backgrounds
A traditional shadow uses box-shadow: 0 4px 12px rgba(0,0,0,0.1). On a light background, the black shadow creates clear depth. On a dark background, the black shadow blends into the backdrop — the card looks completely flat.
Two approaches:
Invert shadow — use rgba(255,255,255,0.08) instead of rgba(0,0,0,0.1). This creates a subtle “glow” instead of a shadow. It works well for low elevation levels (cards, buttons), but can’t produce strong depth.
Double shadow — combine a small dark shadow (to keep grounding) with a large light glow (to create separation):
box-shadow:
0 1px 3px rgba(0,0,0,0.3),
0 0 24px rgba(255,255,255,0.06);
If the design system has only one shadow token, this is the most easily overlooked point when moving to a dark surface.
Button — compound surface
A button has its own background, creating an independent surface inside the page’s surface. This means: a button needs its contrast computed twice.
Primary button — filled with a color opposite to the background:
- On a light background: dark/black button + white text
- On a dark background: light/white button + black text
The text inside the button needs enough contrast against the button’s background, not the page’s. The two layers are entirely independent.
Ghost button — transparent background, with only a border and text. This is the hardest button type because it has no surface of its own — the text and border must have enough contrast directly against the page background. The same ghost button with border: 1px solid rgba(0,0,0,0.3) will fail on mid-tone or dark backgrounds.
Secondary button — has a tinted background (e.g. rgba(0,0,0,0.05)). The tint creates a new surface, but one very close to the page background. The text inside needs to be checked against both the tint surface and the page (since the tint is nearly transparent).
Input field — placeholder is the first thing to fail
An input has three color layers to consider:
-
The input’s background — usually slightly different from the page background (lightly tinted). It needs enough contrast against the page to be recognized as an input area.
-
Border — like a divider, it needs to be visible but not too heavy. Often fails on mid-tone backgrounds.
-
Placeholder text — intentionally muted, usually at 30–40% opacity. On a mid-tone background, the placeholder almost certainly fails WCAG AA. This is the most common error in form design.
WCAG doesn’t require placeholders to pass 4.5:1 (since a placeholder isn’t information required for the form to function), but if the placeholder is the only guidance for the user, it must remain readable.
Focus ring — an accessibility trap few people notice
Browsers default to outline: 2px solid blue or similar. On a dark background or a background that shares the ring’s color, the focus ring disappears or merges with the border.
WCAG 2.2 Focus Appearance (level AA) requires:
- The focus indicator must have an area of at least the component’s perimeter × 2px
- The contrast between the focused and unfocused states must be ≥ 3:1
A practical rule: use a double ring — an inner ring in the surface color + an outer ring in the accent color:
outline: 2px solid var(--accent);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--bg-surface);
This approach creates separation between the element and the focus ring regardless of the background.
Disabled state — intentionally low but with a floor
Disabled elements are exempt from the WCAG contrast requirement (WCAG 2.1 criterion 1.4.3). But “exempt” doesn’t mean they can be invisible.
A disabled state needs to be enough for the user to recognize “this element is here but can’t be interacted with”, not “this element doesn’t exist”. A contrast of around 2.5:1 to 3:1 is the practical range — muted enough to look disabled, visible enough not to be confused with the background.
Same problem as the opacity cascade: disabled text at 22% opacity on a neutral background is one thing; on a mid-tone background the final color can merge completely with the background.
Integrating into this design system
Possible next steps:
-
Add
on-*tokens for each brand color:--on-blue-60: white,--on-red-50: white, etc. Components use these tokens instead of hardcoding. -
A utility function that takes hex/rgb and returns the most suitable token name from the palette. Use it when the color is dynamic (user input, API data).
-
A lint rule that checks whether every hardcoded color pair in a component passes WCAG — run at build time.
Most contrast failures in production don’t come from the main colors — they come from disabled states, placeholders, secondary labels, and helper text. These are where the “tertiary” and “disabled” tokens tend to be underestimated.
References: Hover Micromotion, Button States.