Skip to content
Glazev3.1.0
Color scheme
Contrast
Color scheme
Contrast

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';

HCPair<T>

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 targets

For 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.

ContrastSpec

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 target

Every 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 1 is treated as 1. 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, contrast requires base. Without one, validation throws glaze: color "<name>" has "contrast" without "base". On a color token without a base, the floor is measured against the token’s own input color.
  • contrast together with opacity emits a console.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.

WCAG presets

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.

APCA presets

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.

High-contrast escalation

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 to APCA_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 contrast pair or the inner wcag / apca pair 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, mix value) 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–100 tone range. See Light, dark, and high contrast.

Metric switching

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] }.

Manual contrast level

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 0 and 100 reproduce the classic light / dark and lightContrast / darkContrast output 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 GlazeConfig and GlazeConfigOverride.

What the level interpolates

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.

Un-interpolable tone pairs switch at 50

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 magnitude

Same-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.

Which side of its base a color takes

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 a tone would.

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.

High-contrast output

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 / darkContrast are bit-identical to what 'auto' resolves for those slots.
  • modes.highContrast alone 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() ignores modes and always returns four strings. At a mid level its lightContrast / darkContrast strings 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: modes is 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.

Driving it at runtime

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. Pass contrastLevel: '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 from glaze.configure() is a live reader preference and is left out, so a restored theme still follows the current slider. modes and states are never written to a snapshot. See theme.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 level 100, 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.