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

Contrast solving

Tone deltas space colors evenly; a contrast floor guarantees that a pair is readable. Give a color a base and a contrast spec, and Glaze measures the pair in light, dark, and both high-contrast schemes, then moves the color’s tone only when the measurement falls short.

theme.colors({
  surface: { tone: 97 },
  'surface-text': {
    base: 'surface',
    tone: '-60',
    contrast: 'AA',
  },
});

The floor is a minimum, not a target: a color that already clears it, with a small margin, keeps its authored tone. Use tone for the relationship you want and contrast as the safety net under it. The tone can be a delta from the base or an absolute tone: a base with an absolute tone is a valid way to get a contrast guarantee without a tone relationship.

Floors at a glance

Each text color below asks for a soft tone, '-20' from the surface, and lets its floor decide how far to move. The numbers are measured from the resolved colors in the scheme you are viewing; switch the site’s appearance to see the other three.

theme.ts

ts

import { glaze } from '@tenphi/glaze';

const theme = glaze(280, 80);

theme.colors({
  surface: { tone: 97, saturation: 0.1 },
  'aa-text': {
    base: 'surface',
    tone: '-20',
    saturation: 0.4,
    contrast: 'AA',
  },
  'aaa-text': {
    base: 'surface',
    tone: '-20',
    saturation: 0.4,
    contrast: 'AAA',
  },
  'content-text': {
    base: 'surface',
    tone: '-20',
    saturation: 0.4,
    contrast: { apca: 'content' },
  },
  'body-text': {
    base: 'surface',
    tone: '-20',
    saturation: 0.4,
    contrast: { apca: 'body' },
  },
  'preferred-text': {
    base: 'surface',
    tone: '-20',
    saturation: 0.4,
    contrast: { apca: 'preferred' },
  },
});
aa-textcontrast: 'AA'
Aa ReadableThe quick brown fox
4.55:1 · Lc 68.3floor 4.50:1 · met · tone 47.9
4.55:1 · Lc 38.7floor 4.50:1 · met · tone 61.9
7.07:1 · Lc 80.6floor 7.00:1 · met · tone 33.2
7.07:1 · Lc 48.9floor 7.00:1 · met · tone 67.5
aaa-textcontrast: 'AAA'
Aa ReadableThe quick brown fox
7.07:1 · Lc 80.8floor 7.00:1 · met · tone 33.3
7.07:1 · Lc 58.3floor 7.00:1 · met · tone 76.3
7.07:1 · Lc 80.6floor 7.00:1 · met · tone 33.2
7.07:1 · Lc 48.9floor 7.00:1 · met · tone 67.5
content-textcontrast: { apca: 'content' }
Aa ReadableThe quick brown fox
Lc 60.5 · 3.58:1floor Lc 60.0 · met · tone 55.6
Lc 60.5 · 7.39:1floor Lc 60.0 · met · tone 77.7
Lc 75.5 · 5.85:1floor Lc 75.0 · met · tone 39.4
Lc 75.5 · 11.98:1floor Lc 75.0 · met · tone 84.7
body-textcontrast: { apca: 'body' }
Aa ReadableThe quick brown fox
Lc 75.5 · 5.81:1floor Lc 75.0 · met · tone 39.8
Lc 75.5 · 9.65:1floor Lc 75.0 · met · tone 86.4
Lc 90.5 · 10.65:1floor Lc 90.0 · met · tone 19.6
Lc 90.5 · 15.18:1floor Lc 90.0 · met · tone 92.4
preferred-textcontrast: { apca: 'preferred' }
Aa ReadableThe quick brown fox
Lc 90.5 · 10.56:1floor Lc 90.0 · met · tone 20.0
Lc 90.5 · 12.15:1floor Lc 90.0 · met · tone 93.9
Lc 99.7 · 19.16:1floor Lc 105.0 · not met · tone 0.0
Lc 105.5 · 18.69:1floor Lc 105.0 · met · tone 99.2
Each row is measured against surface in the active scheme. The first number is the floor's own metric; tone is the color's resolved tone (0–100).

Compare the WCAG and APCA rows across light and dark: the two metrics disagree most about light text on a dark background, which is why the choice of metric matters.

WCAG and APCA

Glaze solves two metrics. Pick one per color.

WCAG 2 ratios

The WCAG 2 contrast ratio compares the relative luminance of two colors on a scale from 1:1 to 21:1. It is symmetric: swapping text and background gives the same ratio. A bare number or a preset name means WCAG.

Preset Ratio Use
'AA-large' 3 Large or bold text (WCAG SC 1.4.3)
'AA' 4.5 Body text (WCAG SC 1.4.3)
'AAA-large' 4.5 Large or bold text, enhanced (SC 1.4.6)
'AAA' 7 Body text, enhanced (SC 1.4.6)

APCA lightness contrast

APCA (the Accessible Perceptual Contrast Algorithm) reports a lightness contrast, Lc, from 0 to about 106. It models how text actually reads, so it is polarity-aware: dark text on a light background and light text on a dark background score differently for the same pair of colors. Glaze takes the magnitude and needs to know which color is the text; see Roles and APCA polarity.

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)
'large' 45 Large or bold headlines, fine icons and outlines
'non-text' 30 Solid icons and controls, placeholder or disabled text
'min' 15 Dividers and decoration; APCA’s point of invisibility

The presets are APCA’s Bronze Simple Mode conformance levels and do not depend on the role. You can also pass a raw Lc number.

Which one to use

  • Prefer APCA presets for text, icons, and other content when perceptual readability is the goal: contrast: { apca: 'content' }, or with an explicit high-contrast step, contrast: { apca: ['content', 'body'] }.
  • Use WCAG when compliance, policy, or a migration requires a WCAG ratio: contrast: 4.5, contrast: 'AAA', contrast: { wcag: [4.5, 7] }.
  • Add a floor only where readability or recognizability needs a measured guarantee. Low-stakes relationships — surface ladders, hover steps, subtle fills — read better as tone deltas than as fake contrast floors.

Writing a contrast spec

type ContrastPreset = 'AA' | 'AAA' | 'AA-large' | 'AAA-large';
type ApcaPreset =
  | 'preferred'
  | 'body'
  | 'content'
  | 'large'
  | 'non-text'
  | 'min';
type HCPair<T> = T | [T, T];

type ContrastSpec =
  | number // WCAG ratio
  | ContrastPreset // WCAG preset
  | { wcag: HCPair<number | ContrastPreset> }
  | { apca: HCPair<number | ApcaPreset> };

// On a regular or mix color:
// contrast?: HCPair<ContrastSpec>;

HCPair<T> is either one value for every scheme or a [normal, highContrast] pair. The pair can wrap the whole spec or sit inside the metric:

contrast: 4.5; // WCAG 4.5
contrast: 'AAA'; // WCAG 7
contrast: { wcag: 6 }; // WCAG 6
contrast: [4.5, 7]; // WCAG 4.5 normal / 7 high contrast
contrast: { wcag: [4.5, 7] }; // the same pair, inside the metric
contrast: { apca: 60 }; // Lc 60 normal / 75 high contrast (automatic)
contrast: { apca: 'content' }; // the same, by preset
contrast: { apca: [45, 60] }; // Lc 45 / 60 high contrast (explicit)
contrast: { apca: ['content', 'body'] }; // Lc 60 / 75 (explicit)

A WCAG number below 1 is treated as 1, and a negative APCA number uses its magnitude.

Two definitions are rejected before anything resolves:

  • contrast without base on a theme color — there is nothing to measure against: glaze: color "text" has "contrast" without "base".
  • A pair that switches metric, such as [4.5, { apca: 75 }]. A ratio and an Lc are different scales, so Glaze cannot pick between them or interpolate them. The error starts with glaze: color "text" has a "contrast" pair that switches metric (wcag → apca). and suggests the fix: use one metric for both entries, e.g. { wcag: [normal, highContrast] } (here { wcag: [4.5, 7] }).

Every form, preset, and escalation rule is listed in the API reference under Contrast and high contrast.

How the solver finds a tone

For each scheme, Glaze resolves the base first, then the dependent color:

  1. Preferred tone. The color’s authored tone is placed for this scheme and clamped into the scheme’s tone window. An absolute tone is mapped through the scheme. A tone delta is applied to the base’s tone in that scheme, except in dark under the default mode: 'auto'. There the delta steps from where the base sits on the authored scale: its authored tone, or, for a fixed or static base, an auto contrast-solved base, a mix, or an auto 'max' / 'min' on a base, its dark tone read back through the dark window. The result is inverted and remapped like an absolute tone. At the default dark window the dark step is about 0.87× the delta, mirrored, on every base: a '-4' hover on an AAA button steps +3.5 in dark. High contrast keeps the step exact. How relative tone adapts covers fixed bases, and Overshooting deltas the clamps near the ends of the range. With no tone, the color starts at the base’s own tone.
  2. Keep it if it passes. The pair is measured with the chosen metric. If it clears the floor with a small safety margin, the preferred tone is the result. A preferred tone that clears the floor but not the margin is kept too when no search can beat it: white text at tone 100 has nowhere further to go.
  3. Search one side. Otherwise the solver searches on the side the authored tone points to: darker when the preferred tone is below the base, lighter when it is above. A color with no tone of its own starts on whichever side reaches higher contrast. A tie starts darker: black and white score the same WCAG ratio against a gray at tone 50. A binary search along tone measures the real, chromatic color, and the passing tone nearest the preferred one wins. For WCAG the search first measures the gray tone that gives exactly the ratio. Tone is contrast-uniform for neutrals, so that tone is closed-form. It only narrows the search and never stops it, so a saturated color lands on its own crossing, not the gray’s.
  4. Cross over if needed. With autoFlip on (the default), the opposite side is searched too.

The solver aims slightly above the target — 1% over a WCAG ratio, 0.5 Lc over an APCA target — so rounding in the output formats cannot push the final color under the floor. Measured values therefore sit at or just above the floor: muted-text below lands at 4.55:1 for AA in light and dark.

theme.ts

ts

import { glaze } from '@tenphi/glaze';

const theme = glaze(280, 80);

theme.colors({
  surface: { tone: 97, saturation: 0.1 },
  'muted-text': {
    base: 'surface',
    tone: '-30',
    saturation: 0.4,
    contrast: 'AA',
  },
  'strong-text': {
    base: 'surface',
    tone: '-85',
    saturation: 0.4,
    contrast: 'AA',
  },
});
muted-textcontrast: 'AA'
Without the floor
Aa ReadableThe quick brown fox
2.52:1 · Lc 47.1floor 4.50:1 · not met · tone 67.1
2.18:1 · Lc 15.3floor 4.50:1 · not met · tone 37.8
2.52:1 · Lc 46.9floor 7.00:1 · not met · tone 67.0
2.47:1 · Lc 14.7floor 7.00:1 · not met · tone 33.0
With the floor
Aa ReadableThe quick brown fox
4.55:1 · Lc 68.3floor 4.50:1 · met · tone 47.9
4.55:1 · Lc 38.7floor 4.50:1 · met · tone 61.9
7.07:1 · Lc 80.6floor 7.00:1 · met · tone 33.2
7.07:1 · Lc 48.9floor 7.00:1 · met · tone 67.5
strong-textcontrast: 'AA'
Without the floor
Aa ReadableThe quick brown fox
13.39:1 · Lc 95.4floor 4.50:1 · met · tone 12.1
9.39:1 · Lc 73.8floor 4.50:1 · met · tone 85.5
13.39:1 · Lc 95.2floor 7.00:1 · met · tone 12.0
13.27:1 · Lc 81.7floor 7.00:1 · met · tone 88.0
With the floor
Aa ReadableThe quick brown fox
13.39:1 · Lc 95.4floor 4.50:1 · met · tone 12.1
9.39:1 · Lc 73.8floor 4.50:1 · met · tone 85.5
13.39:1 · Lc 95.2floor 7.00:1 · met · tone 12.0
13.27:1 · Lc 81.7floor 7.00:1 · met · tone 88.0
“Without the floor” is the same theme with contrast removed. muted-text is moved only as far as AA requires; strong-text already clears it and keeps its authored tone.

The preferred tone is clamped into the scheme’s tone window, but the search covers the full 0–100 tone range. A demanding floor can therefore push a color past the window edge — for example, dark-scheme text that needs a very high Lc.

autoFlip: crossing to the other side

Sometimes the authored side cannot reach the floor at all — a light label on a mid-tone button, for instance. autoFlip decides what happens then:

  • On (default): the solver also searches the opposite side of the base and takes the passing tone nearest the preferred one. The light label becomes a dark one. When both sides are equally near — a neutral color with no tone of its own sits exactly between them — it keeps the side it searched first.
  • Off: only the authored side is searched. When it falls short, the color pins to that side’s extreme (tone 0 or 100) and Glaze warns.

theme.ts

ts

import { glaze } from '@tenphi/glaze';

const theme = glaze(280, 80);

theme.colors({
  button: { tone: 68, saturation: 0.6 },
  'button-label': {
    base: 'button',
    tone: '+25',
    contrast: 'AA',
  },
  'button-label-pinned': {
    base: 'button',
    tone: '+25',
    contrast: 'AA',
    autoFlip: false,
  },
});
button-labelcontrast: 'AA'
PublishThe quick brown fox
4.55:1 · Lc 44.1floor 4.50:1 · met · tone 21.1
button-label-pinnedcontrast: 'AA'
PublishThe quick brown fox
2.56:1 · Lc 55.2floor 4.50:1 · not met · tone 100.0
Light scheme. Both labels are authored lighter than the button. With autoFlip the first crosses to the darker side to meet AA; the pinned one stays on the lighter side and misses it. In dark the scheme inverts the authored direction, so the two swap sides.

The same switch governs tone-delta overshoot. When base + delta falls outside 0–100, autoFlip mirrors the delta to the other side of the base ('+30' on a tone-90 surface becomes '-30') instead of clamping at the boundary. If the mirrored tone is also out of range, the original delta is kept and clamped on the authored side.

theme.colors({
  surface: { tone: 90 },
  // '+30' overshoots 100: mirrored to '-30'
  raised: { base: 'surface', tone: '+30' },
  // clamped at 100 instead
  'raised-pinned': {
    base: 'surface',
    tone: '+30',
    autoFlip: false,
  },
});

Set it per color, per theme, or globally:

// one color
theme.colors({
  'fill-label': { base: 'fill', contrast: 'AA', autoFlip: false },
});

// one theme
const strict = glaze(280, 80, { autoFlip: false });

// every theme and token
glaze.configure({ autoFlip: false });

Use autoFlip: false when a color must stay on its side of the base — a tone delta that should never cross over, or an unmet floor you would rather see pinned to one extreme. A mix color has no per-color field; it follows the theme’s (or global) autoFlip.

autoFlip is unrelated to dark-mode inversion. A color swapping ends between light and dark is its adaptation mode at work; autoFlip only reverses a delta or a contrast search around the base.

When both sides pass, “nearest wins” can pick a different side as the target grows. Under a manual contrast level, Glaze therefore decides the preferred side once per half of the slider and switches it only at level 50. A preferred side that stops reaching the floor partway along still falls back to the other side, at whatever level that happens: in glaze(280, 80), a '-5' label with contrast: 'AA-large' on a tone-40 button crosses to the lighter side at level 23 in light.

Roles and APCA polarity

APCA is asymmetric — the Lc of a on b differs from b on a — so Glaze needs to know which of the pair is the foreground. A color’s role decides it. WCAG is symmetric and ignores roles.

Role (polarity) Name keywords
'text' (foreground) text, fg, foreground, content, ink, label, stroke
'border' (foreground) border, divider, outline, separator, hairline, rule
'surface' (background) surface, bg, background, fill, canvas, paper, layer

A foreground color — text, icons, and other content, or a border, divider, or outline — is measured as text over its base. A surface color — a background or fill — is measured as the background, with its base as the text on top.

Below, two colors share one definition. The only difference is the name: accent-text resolves as text and accent-fill as a surface, so the solver measures them in opposite directions and lands on different tones.

theme.ts

ts

import { glaze } from '@tenphi/glaze';

const theme = glaze(280, 80);

theme.colors({
  surface: { tone: 97, saturation: 0.1 },
  'accent-text': {
    base: 'surface',
    tone: '-30',
    contrast: { apca: 'body' },
  },
  'accent-fill': {
    base: 'surface',
    tone: '-30',
    contrast: { apca: 'body' },
  },
});
accent-textcontrast: { apca: 'body' }role text (from the name)
Aa ReadableThe quick brown fox
Lc 75.5 · 5.91:1floor Lc 75.0 · met · tone 41.1roles swapped: Lc 80.0
accent-fillcontrast: { apca: 'body' }role surface (from the name)
Aa ReadableThe quick brown fox
Lc 75.5 · 5.00:1floor Lc 75.0 · met · tone 46.5roles swapped: Lc 70.8
Light scheme, where the difference is largest. accent-text is drawn as text on surface; accent-fill as a fill with surface on top. Lc is measured in the direction each role implies; roles swapped is the Lc the same pair would score used the other way round.

How a role is chosen

For each color, the first rule that applies wins:

  1. An explicit role (a role name or any keyword above as an alias).
  2. Inference from the color’s name, when inferRole is on (the default). The name is split on non-alphanumerics and camelCase humps, and the last recognized keyword wins: button-text is text, input-bg is a surface, card-outline is a border.
  3. The opposite of the base’s role: a color on a surface is text; a color on text or a border is a surface.
  4. 'text' — the base is treated as the background.
const theme = glaze(280, 60);

theme.colors({
  surface: { tone: 90 },
  // role 'text', from the name
  text: { base: 'surface', contrast: { apca: 'content' } },
  // role 'border', from the name
  border: { base: 'surface', tone: '-10' },
  // a fill with no keyword in its name would resolve as text
  // (rule 3), so set the role explicitly
  badge: {
    base: 'surface',
    contrast: { apca: 'content' },
    role: 'surface',
  },
});

Turn name inference off with glaze.configure({ inferRole: false }) or per theme with glaze(hue, saturation, { inferRole: false }); rules 1, 3, and 4 still apply. Shadows and glows always resolve as surfaces. Mix colors take the same chain and the same role field. Good token names do double duty: they describe the palette and give APCA the right polarity without any role fields. Standalone glaze.color() tokens are the exception: their name is a label only, and they default to text (Pairing standalone colors).

High contrast

High-contrast variants (lightContrast, darkContrast) escalate the floor automatically, so most colors need no high-contrast entry at all:

Authored floor Normal High contrast
'AA' 4.5 7 ('AAA')
'AA-large' 3 4.5 ('AAA-large')
'AAA', 'AAA-large' as is unchanged — already the top tier
a bare WCAG number as is unchanged — no successor tier
any APCA target Lc Lc + 15, capped at 106
an explicit pair, outer or inner first second — no escalation

The WCAG step follows the spec’s own Enhanced level (SC 1.4.3 → SC 1.4.6). The APCA step is the Lc analog of AAA over AA. An explicit high-contrast value, in either the outer contrast pair or the inner wcag / apca pair, replaces the automatic step. Use one to hold a ceiling: for large or bold text, where APCA caps useful contrast at Lc 90 to avoid glare, { apca: ['preferred', 'preferred'] } keeps high contrast at Lc 90 instead of boosting it past the cap.

The automatic step can also outrun what a surface allows. An escalated 'preferred' becomes Lc 105, which dark text reaches only on a surface within half a tone of white. On an off-white or tinted surface it pins to black and warns in lightContrast — the preferred-text row in Floors at a glance does this. Give it an explicit, reachable high-contrast entry there, such as { apca: [90, 95] }.

High-contrast variants also ignore the tone windows: both the placement and the search use the full 0–100 range, which gives the escalated floors room to land.

theme.ts

ts

import { glaze } from '@tenphi/glaze';

const theme = glaze(280, 80);

theme.colors({
  surface: { tone: 97, saturation: 0.1 },
  'aa-text': {
    base: 'surface',
    tone: '-20',
    saturation: 0.4,
    contrast: 'AA',
  },
  'large-text': {
    base: 'surface',
    tone: '-20',
    saturation: 0.4,
    contrast: 'AA-large',
  },
  'aaa-text': {
    base: 'surface',
    tone: '-20',
    saturation: 0.4,
    contrast: 'AAA',
  },
  'ratio-text': {
    base: 'surface',
    tone: '-20',
    saturation: 0.4,
    contrast: 4.5,
  },
  'content-text': {
    base: 'surface',
    tone: '-20',
    saturation: 0.4,
    contrast: { apca: 'content' },
  },
  'explicit-text': {
    base: 'surface',
    tone: '-20',
    saturation: 0.4,
    contrast: { apca: ['content', 'content'] },
  },
});
aa-textcontrast: 'AA'
Normal
Aa ReadableThe quick brown fox
4.55:1 · Lc 68.3floor 4.50:1 · met · tone 47.9
4.55:1 · Lc 38.7floor 4.50:1 · met · tone 61.9
4.55:1 · Lc 68.3floor 4.50:1 · met · tone 47.9
4.55:1 · Lc 38.7floor 4.50:1 · met · tone 61.9
High contrast
Aa ReadableThe quick brown fox
7.07:1 · Lc 80.6floor 7.00:1 · met · tone 33.2
7.07:1 · Lc 48.9floor 7.00:1 · met · tone 67.5
7.07:1 · Lc 80.6floor 7.00:1 · met · tone 33.2
7.07:1 · Lc 48.9floor 7.00:1 · met · tone 67.5
large-textcontrast: 'AA-large'
Normal
Aa ReadableThe quick brown fox
3.03:1 · Lc 54.4floor 3.00:1 · met · tone 61.1
3.03:1 · Lc 24.6floor 3.00:1 · met · tone 48.6
3.03:1 · Lc 54.4floor 3.00:1 · met · tone 61.1
3.03:1 · Lc 24.6floor 3.00:1 · met · tone 48.6
High contrast
Aa ReadableThe quick brown fox
4.55:1 · Lc 68.1floor 4.50:1 · met · tone 47.7
4.55:1 · Lc 32.2floor 4.50:1 · met · tone 53.1
4.55:1 · Lc 68.1floor 4.50:1 · met · tone 47.7
4.55:1 · Lc 32.2floor 4.50:1 · met · tone 53.1
aaa-textcontrast: 'AAA'
Normal
Aa ReadableThe quick brown fox
7.07:1 · Lc 80.8floor 7.00:1 · met · tone 33.3
7.07:1 · Lc 58.3floor 7.00:1 · met · tone 76.3
7.07:1 · Lc 80.8floor 7.00:1 · met · tone 33.3
7.07:1 · Lc 58.3floor 7.00:1 · met · tone 76.3
High contrast
Aa ReadableThe quick brown fox
7.07:1 · Lc 80.6floor 7.00:1 · met · tone 33.2
7.07:1 · Lc 48.9floor 7.00:1 · met · tone 67.5
7.07:1 · Lc 80.6floor 7.00:1 · met · tone 33.2
7.07:1 · Lc 48.9floor 7.00:1 · met · tone 67.5
ratio-textcontrast: 4.5
Normal
Aa ReadableThe quick brown fox
4.55:1 · Lc 68.3floor 4.50:1 · met · tone 47.9
4.55:1 · Lc 38.7floor 4.50:1 · met · tone 61.9
4.55:1 · Lc 68.3floor 4.50:1 · met · tone 47.9
4.55:1 · Lc 38.7floor 4.50:1 · met · tone 61.9
High contrast
Aa ReadableThe quick brown fox
4.55:1 · Lc 68.1floor 4.50:1 · met · tone 47.7
4.55:1 · Lc 32.2floor 4.50:1 · met · tone 53.1
4.55:1 · Lc 68.1floor 4.50:1 · met · tone 47.7
4.55:1 · Lc 32.2floor 4.50:1 · met · tone 53.1
content-textcontrast: { apca: 'content' }
Normal
Aa ReadableThe quick brown fox
Lc 60.5 · 3.58:1floor Lc 60.0 · met · tone 55.6
Lc 60.5 · 7.39:1floor Lc 60.0 · met · tone 77.7
Lc 60.5 · 3.58:1floor Lc 60.0 · met · tone 55.6
Lc 60.5 · 7.39:1floor Lc 60.0 · met · tone 77.7
High contrast
Aa ReadableThe quick brown fox
Lc 75.5 · 5.85:1floor Lc 75.0 · met · tone 39.4
Lc 75.5 · 11.98:1floor Lc 75.0 · met · tone 84.7
Lc 75.5 · 5.85:1floor Lc 75.0 · met · tone 39.4
Lc 75.5 · 11.98:1floor Lc 75.0 · met · tone 84.7
explicit-textcontrast: { apca: ['content', 'content'] }
Normal
Aa ReadableThe quick brown fox
Lc 60.5 · 3.58:1floor Lc 60.0 · met · tone 55.6
Lc 60.5 · 7.39:1floor Lc 60.0 · met · tone 77.7
Lc 60.5 · 3.58:1floor Lc 60.0 · met · tone 55.6
Lc 60.5 · 7.39:1floor Lc 60.0 · met · tone 77.7
High contrast
Aa ReadableThe quick brown fox
Lc 60.5 · 3.60:1floor Lc 60.0 · met · tone 55.3
Lc 60.5 · 9.08:1floor Lc 60.0 · met · tone 75.7
Lc 60.5 · 3.60:1floor Lc 60.0 · met · tone 55.3
Lc 60.5 · 9.08:1floor Lc 60.0 · met · tone 75.7
Normal and high-contrast variants of each color, in your current light or dark setting. The floor shown is the one the solver used for that variant.

Any other value that takes an HCPair — tone, shadow and glow intensity, mix value — can tighten the high-contrast variant the same way; see High-contrast pairs.

Manual contrast level

With contrastLevel set to a number, the normal variants solve for a target between the normal and high-contrast floors. Both ends are resolved with the rules above first, so 'AA' ramps from 4.5 at level 0 to 7 at level 100, and an explicit pair ramps between its two entries. A pair that switches metric has no midpoint, which is one reason validation rejects it. Under a level, the side of the base a color prefers is decided once per half of the ramp and switches only at level 50, so nearest-wins can’t swap it back and forth. A preferred side that can no longer reach the floor still falls back to the other side, at any level. The high-contrast variants are not affected by the level. The concept is covered in Light, dark, and high contrast; the full rules are in Contrast and high contrast.

When a floor can’t be met

Some floors are physically out of reach. A mid-gray badge has no tone of text that reaches 7:1 — not black, not white. When neither side passes, the color pins to the extreme of the side it searched first, and Glaze warns.

theme.ts

ts

import { glaze } from '@tenphi/glaze';

const theme = glaze(0, 0);

theme.colors({
  badge: { tone: 50 },
  'badge-text': { base: 'badge', contrast: 'AAA' },
  'badge-text-light': {
    base: 'badge',
    tone: '+10',
    contrast: 'AAA',
  },
});
badge-textcontrast: 'AAA'
Aa ReadableThe quick brown fox
4.94:1 · Lc 34.7floor 7.00:1 · not met · tone 0.0
badge-text-lightcontrast: 'AAA'
Aa ReadableThe quick brown fox
4.25:1 · Lc 74.9floor 7.00:1 · not met · tone 100.0
Light scheme. Neither color can reach 7:1 on a mid-gray badge. badge-text has no tone of its own, so it starts on the stronger side; badge-text-light keeps its authored direction and pins to that side's extreme. In dark the inverted '+10' points darker, so both pin to black.

The side matters: the solver pins to the side it searched first, even when the other extreme would get closer. A color with no tone of its own starts on the stronger side; a color with an authored direction keeps that direction.

The warning names the color, the scheme, the target, and what was reached:

glaze: color "badge-text" cannot meet WCAG 7.00 in light scheme
  (got 4.94).
Try widening the tone window, lowering the contrast target, or picking a
  base color further from this color's tone.

It fires when the result is more than 2% under a WCAG target or more than 1.5 Lc under an APCA target. Each distinct warning prints once per session (the dedupe cache holds 256 entries and starts over when full), so a dev server that re-resolves a theme on every change does not flood the console. For a standalone glaze.color(), pass name so the warning says which token it is.

To fix one:

  • Lower the target, or give the high-contrast variant an explicit, reachable entry: contrast: ['AA', 'AA'] instead of letting AA escalate to AAA.
  • Move the base further from the middle of the tone range.
  • Widen the tone window when the normal scheme is clamping the base. High-contrast variants already use the full range.

A mix with a floor it can’t reach warns too, with its own message; see Contrast on mixes.

Chromatic drift advisory

After every scheme is resolved, Glaze re-measures each regular color that has a floor against its base in that metric and emits an advisory when the measured value is under the target by the same margin:

glaze: color "link" drifts below WCAG 4.50 in light scheme
  (measured <ratio>).
Chromatic luminance differs from the gray tone; nudge the tone or
  saturation if the floor matters.

It is a safety net for the gap between tone and a chromatic color’s real luminance. It never repeats a “cannot meet” warning: a scheme where the solver couldn’t reach the floor is skipped, and so is an achromatic color (saturation 0), whose tone is exact. Because the solver itself measures the chromatic color, a floor it meets passes this check too, so you should not normally see this advisory at all. The model behind it is in Tone and OKHST.

Contrast and opacity

A color with both contrast and opacity resolves, but Glaze warns:

glaze: color "text" has both "contrast" and "opacity".
Opacity makes perceived tone unpredictable.

The floor is solved for the opaque color; what the reader sees depends on what shows through. For a translucent color with a floor, use a transparent mix, whose solver models the browser’s compositing.

Literal colors with a floor

A from color reproduces an exact value in the light, normal-contrast scheme. A floor on it is still a floor: a value that already clears it is emitted untouched, and one that misses moves only as far as the floor requires. Dark and high contrast adapt as usual, and the floor is solved in each of them.

theme.ts

ts

import { glaze } from '@tenphi/glaze';

const theme = glaze(280, 80);

theme.colors({
  surface: { tone: 100, saturation: 0.12 },
  brand: { from: '#2f5bff', base: 'surface', contrast: 3 },
  sky: { from: '#7cc8ff', base: 'surface', contrast: 3 },
});
brandcontrast: 3
Without the floor
BrandThe quick brown fox
5.17:1 · Lc 74.7floor 3.00:1 · met · tone 48.4
3.77:1 · Lc 30.6floor 3.00:1 · met · tone 54.0
5.17:1 · Lc 74.7floor 3.00:1 · met · tone 48.4
4.63:1 · Lc 30.6floor 3.00:1 · met · tone 51.6
With the floor
BrandThe quick brown fox
5.17:1 · Lc 74.7floor 3.00:1 · met · tone 48.4
3.77:1 · Lc 30.6floor 3.00:1 · met · tone 54.0
5.17:1 · Lc 74.7floor 3.00:1 · met · tone 48.4
4.63:1 · Lc 30.6floor 3.00:1 · met · tone 51.6
skycontrast: 3
Without the floor
SkyThe quick brown fox
1.82:1 · Lc 33.7floor 3.00:1 · not met · tone 80.0
1.72:1 · Lc 9.0floor 3.00:1 · not met · tone 26.6
1.82:1 · Lc 33.7floor 3.00:1 · not met · tone 80.0
1.86:1 · Lc 0.0floor 3.00:1 · not met · tone 20.0
With the floor
SkyThe quick brown fox
3.03:1 · Lc 56.4floor 3.00:1 · met · tone 63.1
3.03:1 · Lc 23.8floor 3.00:1 · met · tone 45.2
3.03:1 · Lc 56.4floor 3.00:1 · met · tone 63.1
3.03:1 · Lc 18.5floor 3.00:1 · met · tone 36.0
In light, brand already clears 3:1 on white and keeps its exact value; sky does not, so it darkens just enough.

A from color works as a base like any other color. Here the label is solved against whatever the brand resolves to in each scheme:

theme.colors({
  surface: { tone: 100, saturation: 0.12 },
  brand: { from: '#2f5bff', base: 'surface', contrast: 3 },
  'brand-label': {
    base: 'brand',
    tone: 100,
    contrast: 'AA-large',
  },
});

Pairing standalone colors

glaze.color() tokens use the same solver. Give a token a base — another token, or a raw color value that Glaze wraps with glaze.color(value) — and its contrast is solved against the base’s resolved variant in every scheme.

tokens.ts

ts

import { glaze } from '@tenphi/glaze';

const bg = glaze.color('#1a1a2e');
const text = glaze.color({
  from: '#ffffff',
  base: bg,
  contrast: 'AA',
});
Aa ReadableThe quick brown fox
17.06:1 · Lc 106.3floor 4.50:1 · met · tone 100.0
11.58:1 · Lc 82.4floor 4.50:1 · met · tone 9.3
17.06:1 · Lc 106.3floor 7.00:1 · met · tone 100.0
16.92:1 · Lc 91.6floor 7.00:1 · met · tone 0.0
White text paired with a dark navy token, measured in the active scheme. In dark, the navy base inverts and the text follows it.

With a base:

  • contrast is solved per scheme against the base’s light, dark, lightContrast, and darkContrast variants: what the base token paints, under its own config.
  • A tone delta (tone: '+N' / '-N') is measured from the base, exactly in light and high contrast. Under the default mode: 'auto', the dark variant steps from where the base token sits on its own authored scale, read under its own lightTone; for a fixed or static base, or an auto base placed by a contrast floor or an extreme, that is its own dark tone read back through the dark window, as in a theme. The step flips direction and comes out about 0.87× the delta at the default dark window, the same as a theme color (How relative tone adapts). A base whose dark window differs from the token’s (its own darkTone, or another contrastLevel) is read back through the token’s dark window, so a '+0' lands on it in dark. A relative hue still anchors to the token’s own seed value.
  • Pairs chain: a paired token can be the base of the next one.
const page = glaze.color('#ffffff');
const button = glaze.color({
  from: '#1e40af',
  base: page,
  contrast: 3,
  name: 'button-fill',
});
const label = glaze.color({
  from: '#ffffff',
  base: button,
  contrast: 'AA',
  name: 'button-label',
});

In light all three keep their exact values, since each already clears its floor. In dark they invert, and the label is solved against the inverted button.

Without a base, a token’s contrast (and a tone delta) is measured against the token’s own literal value, held fixed in every scheme.

Either way the token is the foreground: APCA measures it as text on its anchor, the base or its own literal value. Set role: 'surface' for a token that is a fill with its anchor drawn on top. A token’s name never changes the role — button-fill above is a label for warnings, and its WCAG floor ignores roles anyway. Base resolution timing, structured-base linking, and the name label are covered in Color tokens and Roles on tokens.

Contrast on mixes

Mix colors take the same contrast field. Instead of a tone, the solver adjusts the mix ratio of an opaque mix or the opacity of a transparent one, against the mix’s base:

theme.colors({
  tint: {
    type: 'mix',
    base: 'surface',
    target: 'accent',
    value: 10,
    contrast: 'AA',
  },
  overlay: {
    type: 'mix',
    base: 'surface',
    target: 'accent',
    value: 5,
    blend: 'transparent',
    contrast: 3,
  },
});

Both value and contrast accept [normal, highContrast] pairs. When even the pure target can’t meet the floor, the mix settles at the pure target. A value of 100 is the pure target already. It is kept whenever it clears the floor, even inside the solver’s safety margin. When it misses, there is no room above, so the mix falls back to the pure base, and a transparent mix ends at alpha 0. That includes a high-contrast entry of 100, as in value: [80, 100]. Either way Glaze warns, with the same margins and dedupe as a regular color. Here accent-wash, a 10% mix toward a tone-55 accent on a tone-97 surface in glaze(280, 80), can’t reach AA:

glaze: mix "accent-wash" cannot meet WCAG 4.50 against its base in
  light scheme (got 3.54).
Even the pure target falls short; try lowering the contrast target or
  mixing toward a color further from the base.

A tone window has no say in a mix, so the fix is a target further from the base or a lower floor. The drift advisory skips mixes. See Contrast on mixes.

Using the solver directly

The solver is exported for tools that work outside a theme: findToneForContrast, findValueForMixContrast, resolveContrastForMode, resolveContrastForLevel, resolveMinContrast, resolveApcaTarget, apcaContrast, and the constants APCA_PRESETS, APCA_HC_ENHANCEMENT (15), and APCA_MAX_LC (106). The role helpers — normalizeRole, inferRoleFromName, roleToPolarity, oppositeRole — are exported too. Their signatures and options are in the API reference under Utilities.