Contrast and high contrast
This page lists the contrast types, presets, and rules. For how the solver finds a tone, when to pick WCAG or APCA, and live examples, see Contrast solving. For how a color adapts across schemes, see Light, dark, and high contrast.
import type {
HCPair,
ContrastSpec,
MinContrast,
ContrastPreset,
ApcaPreset,
} from '@tenphi/glaze';A value used in both contrast tiers, or an explicit [normal, highContrast] pair:
type HCPair<T> = T | [T, T];The first entry drives the light and dark variants, the second lightContrast and darkContrast. A single value applies to both.
| Field | Type | Pair example |
|---|---|---|
tone on a regular color |
HCPair<ToneValue> |
tone: ['-8', '-16'] |
contrast on a regular or mix color |
HCPair<ContrastSpec> |
contrast: [4.5, 7] |
intensity on a shadow or glow |
HCPair<number> |
intensity: [8, 14] |
value on a mix |
HCPair<number> |
value: [10, 20] |
tone and contrast on a color token |
HCPair<…> |
tone: [74, 80] |
tone: '-8'; // -8 in normal and high contrast
tone: ['-8', '-16']; // -8 normal, -16 high contrast
contrast: { apca: 'content' }; // preset, auto-escalated in high contrast
contrast: { apca: ['content', 'body'] }; // explicit normal / HC targetsFor contrast, the pair can wrap the whole spec or sit inside the metric (see ContrastSpec). An explicit high-contrast value in either position turns off the automatic escalation for that color.
Under a numeric contrastLevel, the normal variants are resolved between the two entries.
A contrast floor with a choice of metric:
type ContrastPreset = 'AA' | 'AAA' | 'AA-large' | 'AAA-large';
type ApcaPreset =
| 'preferred'
| 'body'
| 'content'
| 'large'
| 'non-text'
| 'min';
type MinContrast = number | ContrastPreset; // a bare WCAG target
type ContrastSpec =
| number // WCAG ratio
| ContrastPreset // WCAG preset
| { wcag: HCPair<number | ContrastPreset> }
| { apca: HCPair<number | ApcaPreset> }; // APCA Lc targetEvery contrast field takes HCPair<ContrastSpec>: RegularColorDef, MixColorDef, and the color token inputs.
| Form | Metric | Example | Normal | High contrast |
|---|---|---|---|---|
| Number | WCAG | contrast: 4.5 |
4.5 | 4.5 |
| WCAG preset | WCAG | contrast: 'AAA' |
7 | 7 |
{ wcag } |
WCAG | contrast: { wcag: 6 } |
6 | 6 |
| Outer pair | WCAG | contrast: [4.5, 7] |
4.5 | 7 |
| Inner pair | WCAG | contrast: { wcag: [4.5, 7] } |
4.5 | 7 |
| Pair of objects | WCAG | contrast: [{ wcag: 4.5 }, { wcag: 7 }] |
4.5 | 7 |
{ apca } number |
APCA | contrast: { apca: 60 } |
Lc 60 | Lc 75 (escalated) |
{ apca } preset |
APCA | contrast: { apca: 'content' } |
Lc 60 | Lc 75 (escalated) |
| Inner APCA pair | APCA | contrast: { apca: [45, 60] } |
Lc 45 | Lc 60 |
| Inner APCA preset pair | APCA | contrast: { apca: ['content', 'body'] } |
Lc 60 | Lc 75 |
- A bare number or preset means WCAG. Use
{ wcag }/{ apca }to name the metric. - A WCAG number below
1is treated as1. A negative APCA number uses its magnitude. - The floor is measured against the color’s
base, in each scheme separately. It is a floor, not a target: a color whose preferred tone already clears it with the solver’s small margin (×1.01 for WCAG, +0.5 Lc for APCA) keeps it, and one that misses moves only as far as the floor requires. How the search works,autoFlip, and unreachable targets are covered in Contrast solving. - WCAG is symmetric. APCA is not, so a color’s role decides which side of the pair is measured as text.
- On a theme color,
contrastrequiresbase. Without one, validation throwsglaze: color "<name>" has "contrast" without "base".On a color token without abase, the floor is measured against the token’s own input color. contrasttogether withopacityemits aconsole.warn: a translucent color has no fixed perceived tone.- A floor that is physically unreachable pins a regular color or token to the extreme of the side the solver searched first (not necessarily the closer one) and warns. A mix pins its ratio or opacity to the end of the range it searched and warns too: normally the pure target, but a mix whose value is already 100 (including the high-contrast entry of
value: [80, 100]) can only search toward the base, so it pins to the pure base. A preferred tone or value that clears the floor itself is never given up for an unmet extreme: a value-100 mix whose pure target passes keeps it. Tone is contrast-uniform for grays, so Glaze also re-measures each chromatic regular color whose floor the solver met and emits a deduped drift advisory if it lands under the floor; the solver measures the real color, so this normally stays silent. See When a floor can’t be met and Warnings.
ContrastPreset:
| Preset | Ratio | WCAG criterion | High-contrast successor |
|---|---|---|---|
'AA-large' |
3 | SC 1.4.3, large text | 'AAA-large' (4.5) |
'AA' |
4.5 | SC 1.4.3 | 'AAA' (7) |
'AAA-large' |
4.5 | SC 1.4.6, large text | — (top tier) |
'AAA' |
7 | SC 1.4.6 | — (top tier) |
resolveMinContrast(value) maps a MinContrast to its ratio; see Resolving a spec.
ApcaPreset keywords are the APCA Bronze Simple Mode conformance levels. They don’t depend on the role.
| Preset | Lc | Use |
|---|---|---|
'preferred' |
90 | Preferred body and column text |
'body' |
75 | Minimum for body and column text |
'content' |
60 | Readable non-body content (about WCAG AA 4.5:1) |
'large' |
45 | Large or bold headlines; fine icons and outlines (about 3:1) |
'non-text' |
30 | Solid icons and controls; placeholder and disabled text |
'min' |
15 | Dividers and decoration; APCA’s point of invisibility |
The same map is exported as APCA_PRESETS (see Constants and types). A raw Lc number works anywhere a preset does.
Without an explicit high-contrast entry, the lightContrast / darkContrast variants solve for a stronger floor:
| Authored | Normal | High contrast |
|---|---|---|
'AA', { wcag: 'AA' } |
4.5 | 7 ('AAA') |
'AA-large', { wcag: 'AA-large' } |
3 | 4.5 ('AAA-large') |
'AAA', 'AAA-large' |
7, 4.5 | Unchanged — already the top WCAG tier. |
A WCAG number (4.5, { wcag: 6 }) |
as written | Unchanged — a bare ratio has no successor tier. |
| An APCA number or preset | Lc | Lc + 15, capped at 106 (a target already above 106 is kept). |
| An explicit pair, outer or inner | first entry | second entry, no escalation. |
- WCAG promotion follows the spec’s own Enhanced level, SC 1.4.3 → SC 1.4.6.
- APCA Enhanced Level adds
APCA_HC_ENHANCEMENT(15 Lc), the Lc analog of AAA over AA, and clamps the result toAPCA_MAX_LC(106). The cap only limits the boost: a normal target already above 106 stays as it is, never lower than the normal tier’s. - An explicit high-contrast value in the outer
contrastpair or the innerwcag/apcapair replaces the automatic step. Use one to hold a ceiling. APCA caps large or bold text at Lc 90 to avoid glare, so for a heading write{ apca: ['preferred', 'preferred'] }rather than letting high contrast boost it past 90. - Other pairs (
tone,intensity, mixvalue) have no automatic escalation: a single value is used in both tiers. - Tone windows don’t apply in high contrast: placement and the contrast search use the full
0–100tone range. See Light, dark, and high contrast.
A contrast pair can’t switch metric between its entries. A WCAG ratio and an APCA Lc are different scales, so [4.5, { apca: 75 }] has no target between its ends, and even without a manual level the two variants would be judged incomparably. Validation throws:
glaze: color "text" has a "contrast" pair that switches metric
(wcag → apca).
A WCAG ratio and an APCA Lc are different scales; use one metric for
both entries, e.g. { wcag: [normal, highContrast] }.Pair values inside one metric instead: { wcag: [4.5, 7] } or { apca: [60, 90] }.
contrastLevel puts the normal end of the two-tier contrast model on a 0–100 slider. It is a config field, so it works globally, per theme, per token, and through extend():
import { glaze } from '@tenphi/glaze';
glaze.configure({ contrastLevel: 60 });
const theme = glaze(280, 80, { contrastLevel: 60 });
const brand = glaze.color('#26fcb2', { contrastLevel: 60 });The level moves the normal variants, light and dark. It says nothing about high contrast: lightContrast / darkContrast stay the true high-contrast resolution at every level, and modes.highContrast alone decides whether they are emitted.
| Value | Meaning |
|---|---|
'auto' |
Default. The normal variants take the authored normal entries as they are. |
0 |
The same output as 'auto', reached through the slider. |
100 |
The normal variants are the high-contrast ones. Set globally, the separate tier is dropped as a duplicate; see High-contrast output. |
1–99 (any number between) |
The normal variants are resolved at that level. |
- Levels
0and100reproduce the classiclight/darkandlightContrast/darkContrastoutput bit for bit. - Numbers are clamped to
0–100. A non-finite number, or any value other than a number or'auto'(such as the string'50'), throws:glaze: contrastLevel must be a finite number 0–100 or 'auto' (got NaN). - The field is listed with the rest of the config in
GlazeConfigandGlazeConfigOverride.
Exactly the three things that make a high-contrast variant differ from its normal one:
| Mechanism | At level L |
|---|---|
Authored HCPairs: tone, contrast, shadow / glow intensity, mix value |
The two entries are interpolated. |
| The tone-window bypass | Each window endpoint moves linearly toward the full range: light 10 → 0, dark 15 → 0 and 95 → 100 with the defaults. The render eps is not interpolated. |
Contrast escalation (AA → AAA, APCA +15 Lc) |
Both ends are resolved with the escalation rules, then the numeric target is interpolated. 'AA' at level 50 solves for 5.75; { apca: 'content' } at level 25 for Lc 63.75. |
The interpolated values go through the ordinary resolve, so a contrast floor at any level is solved, not approximated. Which side of its base a solved color prefers is fixed once per half of the ramp; see Which side of its base a color takes.
They feed the two normal passes only. The high-contrast passes ignore the level and resolve exactly as in 'auto' mode.
A shadow / glow step that the level puts out of reach pins to its strongest composite as usual, but at any level other than 0 it warns only when it is out of reach in both light and dark (the same rule as in the high-contrast passes). See When Glaze moves the surface.
Hue, saturation, darkDesaturation, opacity, and pastel don’t vary with high contrast, so the level doesn’t change them.
A pair whose entries are of different kinds has no midpoint: blending them would change which resolution rule applies partway up the ramp. These pairs use the normal entry below level 50 and the high-contrast entry from 50 up:
tone: [50, 'max']; // absolute vs extreme
tone: [50, '+20']; // absolute vs delta (on a color with a base)
tone: ['max', 'min']; // extremes carry no magnitudeSame-kind pairs interpolate smoothly: [30, 20], ['-6', '-12'], { wcag: [4.5, 7] }, [{ apca: 60 }, { apca: 90 }]. Numeric pairs (intensity, value) always interpolate.
A contrast pair can’t switch metric at all; see Metric switching.
autoFlip decides per solve, and its tie-break (when both sides of the base meet the floor, take the one nearer the preferred tone) depends on the target. Along a ramp of targets that tie-break can change, so a naive solve would let a color jump across its own base as the slider moves.
Under a manual level, each level first runs a probe: a solve at the nearer endpoint’s target (level 0 below 50, level 100 from 50 up), from that level’s preferred tone and base. The color then prefers the side the probe lands on:
- While the preferred side meets the floor, the color stays there, even where the other side would be nearer its preferred tone.
- The probe’s target is the same across each half of the ramp, so the preferred side normally holds through it and changes only at level 50, where un-interpolable pairs also switch, when the two endpoints land on different sides. The preferred tone and the base move with the tone windows, though, so a color whose endpoint solve is close to a tie (both sides about equally near its preferred tone) can switch sides at another level.
- Flipping is re-ordered, not disabled. At a level where the preferred side can’t reach the interpolated target, the solver falls back to the opposite side, so the floor is met wherever either side can meet it. Where neither side can, the color pins to the preferred side’s extreme. A color near the limit of what either side can reach can therefore cross its base at other levels, and back again, even when both endpoints land on the same side.
- A probe that lands on its base pins no side. With
contrast: [1, 4.5], the level-0 probe keeps the preferred tone, and for a'+0'delta that is the base’s own tone. Each level then picks a side by the solver’s own tie rule, exactly as the same color without atonewould.
For example, in a gray theme (glaze(0, 0)), a static tone-95 color with contrast: [3, 6] on a static tone-60 base stays above the base through level 12, where it reaches white, and sits below it from level 13 (tone 19.6) on. White reaches only 3.38:1 against the base, and the target, rising from 3 to 6, passes that at level 12.7.
A color can also cross and come back. In the same theme, black reaches 4.55:1 against a static tone-49.77 base and white 4.61:1. A color on that base with tone: '-1' and contrast: 'AA' sits below it in light at both endpoints, but at levels 3 and 4 only white meets the rising target, so it moves above the base. From level 5 neither side meets the target, and it pins back to black.
The underlying solver option is preferInitial on findToneForContrast, for the same behavior in a custom pipeline.
The level and the high-contrast tier are independent, and they compose: the slider raises the baseline, and a prefers-contrast: more block still escalates on top of whatever level the reader chose.
resolve()returns four variants at every level.lightContrast/darkContrastare bit-identical to what'auto'resolves for those slots.modes.highContrastalone decides whether the tier is emitted, as in'auto'mode. A level doesn’t turn it on or off, with the one exception below.css()ignoresmodesand always returns four strings. At a mid level itslightContrast/darkContraststrings carry escalated values that differ from the normal ones, so an existing@media (prefers-contrast: more)block keeps doing real work.- A level on a single theme or token never changes which modes are emitted:
modesis global, and one palette sibling must not collapse the shared token structure.
contrastLevel |
modes.highContrast: false (default) |
modes.highContrast: true |
|---|---|---|
'auto' or 0 |
light, dark |
+ lightContrast, darkContrast |
1–99 |
light, dark at the level |
+ the tier, unchanged by the level |
100, set globally |
light, dark equal the high-contrast values |
The same: the tier is dropped as a duplicate. |
At a global level of 100 the normal variants are the high-contrast ones, so a separate tier would be an exact copy. Glaze emits a single light / dark set and suppresses even an explicit per-call modes: { highContrast: true }. A level of 100 set on one theme or token still reports into the tier, with values equal to its own normal ones.
configure() bumps the config version, which invalidates every theme and token cache, so a slider only needs to re-export:
import { glaze } from '@tenphi/glaze';
const theme = glaze(280, 80);
theme.colors({
surface: { tone: 97 },
'surface-text': { base: 'surface', tone: '-52', contrast: 'AA' },
});
const slider = document.querySelector<HTMLInputElement>(
'#contrast-level',
)!;
const style = document.head.appendChild(document.createElement('style'));
slider.oninput = () => {
glaze.configure({ contrastLevel: slider.valueAsNumber });
const { light, dark } = theme.css();
style.textContent =
`:root { ${light} }\n` +
`@media (prefers-color-scheme: dark) { :root { ${dark} } }`;
};A resolve at a manual level runs the same four passes as an 'auto' one. Only level 100 skips the two high-contrast passes, because the normal passes already produced those values.
configure()never clears a field by omission.glaze.configure({})keeps the current level. PasscontrastLevel: 'auto'to leave manual mode, globally or on one theme of a palette..export()freezes only an authored level. A level set on the instance, or passed to.export(override), is written to the snapshot. A level inherited fromglaze.configure()is a live reader preference and is left out, so a restored theme still follows the current slider.modesandstatesare never written to a snapshot. Seetheme.export().- Set the level globally, or on both sides of a base link. A dependent color token reads its base token as the base resolved it, so a level set on only one of them solves the dependent’s floor at its own level against a base at another. Tone deltas still step cleanly: an
'auto'base under another level’s dark window is read back through the link’s, so a'+0'link lands on its base and dark steps use the link’s scale. In the high-contrast tier, a dependent reads its base’s high-contrast slots (and at level100, the base’s mirrored normal ones).
resolveContrastForLevel(spec, level, polarity?) resolves a contrast spec at a level the same way, for custom pipelines. For levels strictly between 0 and 100 it throws on a pair that switches metric. See Resolving a spec.