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

Light, dark, and high contrast

You define each color once. Glaze resolves it four times — light, dark, light high contrast, and dark high contrast — by moving its tone through a per-scheme pipeline. Hue and saturation stay as you authored them, apart from a small dark desaturation, unless you give dark a seed of its own.

Light
Quarterly reportRevenue is up 12%Every color on this card was resolved by Glaze for the scheme you are viewing.
PublishView details
Dark
Quarterly reportRevenue is up 12%Every color on this card was resolved by Glaze for the scheme you are viewing.
PublishView details
Light · high contrast
Quarterly reportRevenue is up 12%Every color on this card was resolved by Glaze for the scheme you are viewing.
PublishView details
Dark · high contrast
Quarterly reportRevenue is up 12%Every color on this card was resolved by Glaze for the scheme you are viewing.
PublishView details
One theme in all four schemes. Every color is resolved by Glaze for its scheme; none is tuned by hand.

Four variants per color

Every color resolves to four variants:

Variant Scheme Window
light Light lightTone, default [10, 100] in OKHSL lightness (tone 4.95–100)
dark Dark darkTone, default [15, 95] in OKHSL lightness (tone 9.25–95.9), inverted for auto
lightContrast Light, high contrast The full range, tone 0–100
darkContrast Dark, high contrast The full range, inverted for auto

theme.resolve() always returns all four. Exports include light and dark by default; output modes choose which variants they emit.

For a regular color, each variant runs the same pipeline:

authored tone
  -> invert (100 - t) when the scheme is dark and mode is 'auto'
  -> remap into the scheme's tone window (the full range in high contrast)
  -> store the canonical tone; convert to OKHSL lightness when formatting

The tone axis itself — why equal tone steps give equal contrast, and the transfer math — is covered in Tone and OKHST.

Dark is an inversion

Under the default mode: 'auto', dark mode is a single inversion: an authored tone t becomes 100 − t, which is then remapped into the dark window. There is no fitted curve. Because tone is contrast-shaped, the inversion itself keeps the authored spacing — exactly for neutrals, approximately for chromatic colors, whose luminance drifts a little with hue and saturation. The dark window then scales every step to its span, about 0.87× at the defaults, so order and direction carry over but steps come out a little smaller in dark. Tone deltas scale the same way on every base; see How relative tone adapts.

A surface at tone 97 becomes a dark surface, and text 52 tones below it becomes light text above it. Hierarchy, ladders, and borders keep their order without a second definition.

Tone windows

Ordinary schemes don’t use the whole tone scale. Each one has a tone window:

glaze.configure({
  lightTone: [10, 100], // default
  darkTone: [15, 95], // default
});

The bounds are OKHSL lightness values. Glaze converts them to tone — the light window spans tone 4.95 to 100, the dark window 9.25 to 95.9 — and spreads the whole authored 0–100 scale across that interval. The window is a remap, not a clamp: an authored tone of 0 renders at tone 4.95 in light, and every other tone moves proportionally. Per scheme and mode:

light:          t' = remap(t, lightTone)
dark, auto:     t' = remap(100 - t, darkTone)
dark, fixed:    t' = remap(t, darkTone)
static:         t' = t
high contrast:  the same, with the window [0, 100]

remap(t, [lo, hi]) = tone(lo) + t / 100 × (tone(hi) - tone(lo))
Authored tone
0
10
20
30
40
50
60
70
80
90
100
Lightmode: 'auto'
4.95
14.5
24.0
33.5
43.0
52.5
62.0
71.5
81.0
90.5
100
Darkmode: 'auto'
95.9
87.2
78.6
69.9
61.2
52.6
43.9
35.2
26.6
17.9
9.25
Authored tones 0–100 (top row) and the tone Glaze stores for each in light and dark. The light window lifts the bottom of the scale; the dark window reverses it and trims both ends.

A window takes three forms:

  • [lo, hi] — OKHSL lightness bounds at the reference eps. The common form.
  • { lo, hi, eps } — the same bounds with an explicit render eps for that scheme (the reference eps is 0.05). Glaze still stores the result on the reference scale, so deltas and contrast stay comparable. Advanced; most palettes keep the default.
  • false — the full range [0, 100]. This removes the boundaries, not the tone transfer.

Set windows globally with glaze.configure() or per theme or token with a config override (glaze(280, 80, { darkTone: [10, 98] })). The defaults and every field are listed in Configuration.

Some colors skip a window:

  • High-contrast variants ignore both windows and use the full range. See High contrast.
  • mode: 'static' colors skip both windows in every scheme. See Adaptation modes.
  • A color seeded with from (a literal hex, rgb(), oklch(), … value) skips the light window, so its light variant reproduces the literal exactly, and so does its light high-contrast variant, whose window is the full range anyway. Dark, dark high contrast, and a contrast floor still adapt it. See from.
  • An extreme with a base (tone: 'max' / 'min') can land outside the dark window. See Extremes in dark.

Adaptation modes

A color’s mode decides how its tone moves between schemes. In light, auto and fixed both remap the authored tone into the light window, and static keeps it as authored. They differ in dark:

Mode In dark Use it for
'auto' (default) Inverted, then remapped into the dark window Surfaces, text, borders, icons
'fixed' Remapped into the dark window, not inverted Brand fills, CTAs, status banners
'static' The authored tone, as in light Logos and other exact colors

auto suits anything that should trade places with its background in dark; fixed keeps a color on its side of the scale, as brand fills and inverse surfaces need.

theme.ts

ts

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

const theme = glaze(280, 60);

theme.colors({
  'pale-auto': { tone: 92 },
  'deep-auto': { tone: 25 },
  'pale-fixed': { tone: 92, mode: 'fixed' },
  'deep-fixed': { tone: 25, mode: 'fixed' },
  'pale-static': { tone: 92, mode: 'static' },
  'deep-static': { tone: 25, mode: 'static' },
});
LightDark
mode: 'auto'
tone 92.4
tone 28.7
tone 16.2
tone 74.2
mode: 'fixed'
tone 92.4
tone 28.7
tone 89.0
tone 30.9
mode: 'static'
tone 92.0
tone 25.0
tone 92.0
tone 25.0
The same two tones under each mode. auto swaps them, fixed keeps them on their side and only remaps them into the dark window, static renders the authored tone everywhere.

static skips more than the window: it also skips dark desaturation and any dark seed, so the color is pinned to one tone, hue, and saturation. In high-contrast variants, auto still inverts and fixed / static don’t.

For a worked palette that picks a mode per token — surfaces and text auto, accent fills fixed — see Designing a palette.

How relative tone adapts

A tone delta ('+N' / '-N') places a dependent color relative to its base, and the base is resolved separately in each scheme. What happens to the delta depends on the dependent’s mode:

  • auto — in light, the delta is applied to the base’s light tone as written. In dark, Glaze steps from where the base sits on the authored scale (its anchor), then inverts and remaps the result through the dark window, exactly like an authored tone. The step flips direction, keeps its place in the ladder, and comes out the dark window’s scale times the delta, about 0.87× at the defaults: '-4' becomes +3.5 in dark on a tone-97 surface and on a tone-50 base alike (and '+4' on the tone-50 base becomes −3.5), and a chain of deltas steps the same way at every link. In high contrast the windows are the full range, so at the reference dark eps the step is exact in both schemes, except near the ends of the scale (Overshooting deltas).
  • fixed — the delta is applied to the base’s tone in each scheme with its sign preserved. A '+30' label stays 30 tones lighter than its fill in light and in dark. A label on a fixed fill should be fixed too: the inversion follows the label’s own mode, so an auto label inverts in dark and lands on the other side of its fill (30 tones above it in light, 26.0 below it in dark).
  • static — the delta is applied the same way as fixed, to the base’s tone in each scheme, without inversion. A static dependent of an adaptive base therefore still moves with that base; only its own tone mapping is skipped.

theme.ts

ts

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

const theme = glaze(280, 30);

theme.colors({
  surface: { tone: 97 },
  'surface-2': { base: 'surface', tone: '-4' },
  border: { base: 'surface', tone: '-12' },
  text: { base: 'surface', tone: '-52' },
  fill: { tone: 52, mode: 'fixed' },
  'fill-text': { base: 'fill', tone: '+30', mode: 'fixed' },
});
LightDarkLight HCDark HC
surface
tone 97.1
tone 11.9
tone 97.0
tone 3.0
surface-2
tone 93.1Δ -4.01.13:1
tone 15.3Δ +3.51.11:1
tone 93.0Δ -4.01.13:1
tone 7.0Δ +4.01.13:1
border
tone 85.1Δ -12.01.45:1
tone 22.2Δ +10.41.37:1
tone 85.0Δ -12.01.45:1
tone 15.0Δ +12.01.43:1
text
tone 45.1Δ -52.04.93:1
tone 56.9Δ +45.13.92:1
tone 45.0Δ -52.04.93:1
tone 55.0Δ +52.04.83:1
fill
tone 54.4
tone 54.3
tone 52.0
tone 52.0
fill-text
tone 84.4Δ +30.02.51:1
tone 84.3Δ +30.02.51:1
tone 82.0Δ +30.02.51:1
tone 82.0Δ +30.02.51:1
Each dependent is drawn over its base, with its tone step from the base (Δ) and the measured WCAG ratio. The auto ladder flips in dark with every step about 0.87× as large; the fixed pair keeps +30 everywhere; high contrast keeps every step exact.

The text row shows why legibility belongs to contrast, not to a delta: the same '-52' step measures 4.93:1 in light and 3.92:1 in dark. A delta is a visual relationship; a contrast floor is a guarantee that is solved in every scheme.

Where a dark step starts

An auto dependent steps in dark from its base’s anchor: where the base sits on the authored scale, from 0 to 100, which is the tone an auto color would be authored at to land where the base sits in dark. Glaze finds the anchor from how the base got its tone:

Base Anchor
An auto color placed by a tone: a number, a from color, or an extreme with no base Its authored tone
An auto tone delta Its base’s anchor plus the delta
An auto color with no tone or contrast of its own Its base’s anchor
Any other color: fixed or static, auto with a contrast floor, an auto extreme on a base, or a mix Its own dark tone, read back through the dark window

The fourth row is why a dependent follows where its base lands in dark, not where it sits in light. A contrast floor solves the base’s dark tone on its own, and a mix blends two dark variants, yet the steps off them stay even:

theme.ts

ts

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

const theme = glaze(280, 40);

theme.colors({
  surface: { tone: 97 },
  button: { base: 'surface', contrast: 'AAA' },
  'button-hover': { base: 'button', tone: '-4' },
  wash: {
    type: 'mix',
    base: 'surface',
    target: 'button',
    value: 30,
  },
  'wash-hover': { base: 'wash', tone: '-6' },
});
LightDarkLight HCDark HC
button
tone 33.5Δ -63.7
tone 76.1Δ +64.2
tone 33.3Δ -63.7
tone 67.5Δ +64.5
button-hover
tone 29.5Δ -4.0
tone 79.6Δ +3.5
tone 29.3Δ -4.0
tone 71.5Δ +4.0
wash
tone 81.0
tone 31.5
tone 80.9
tone 19.5
wash-hover
tone 75.0Δ -6.0
tone 36.7Δ +5.2
tone 74.9Δ -6.0
tone 25.5Δ +6.0
Hovers on a contrast-solved button and on a mix. The button's own step from surface differs between light and dark, because the floor is solved per scheme, yet button-hover steps +3.5 in dark and wash-hover +5.2, both 0.87× their delta; high contrast keeps them exact.

A fixed or static base doesn’t invert: it keeps its side of the scale, or, as a fixed delta, rides its own base into dark with its sign kept. An auto dependent still inverts, so it mirrors to the other side of wherever the base sits. In glaze(280, 30), an auto '+30' label on a fixed fill at tone 52 lands 26.0 below it in dark, and an auto '-4' hover on { base: 'surface', tone: '-10', mode: 'fixed' } over a tone-97 surface steps +3.5. Make the dependent fixed too when it should keep its side with its base, as a label on a brand fill does.

The 0.87× scale holds at the reference dark eps. A custom dark eps bends the dark window’s curve, so a dark delta lands where the same authored tone would rather than at a fixed scale: under darkTone: { lo: 15, hi: 95, eps: 0.2 }, '+4' and '-4' on a tone-50 base step −3.1 and +3.0 in dark, exactly where tones 54 and 46 land. High contrast keeps the dark window’s eps, so its dark steps bend the same way.

Overshooting deltas

When base + delta falls outside [0, 100], the delta is mirrored to the other side of the base — '+10' on a tone-97 surface resolves to 87.1 in light instead of clamping at 100. If the mirrored target is also out of range, the original delta is kept and clamped on the authored side. This is autoFlip, on by default; set autoFlip: false on the color (or globally) to always clamp on the authored side. Under mode: 'auto' the flip is decided on the light tones, and dark mirrors whichever side light took.

Because dark steps from the anchor rather than the light tone, it has two limits of its own:

  • A step that runs off the scale. The flip is decided on the base’s light tone, so a step that fits there can still run past 0 or 100 from the anchor. In dark it then stops at the dark window’s edge. An auto base placed by a tone meets this at the bottom, because the light window lifts authored tone 0 to 4.95: '-4' on a tone-2 base steps about +1.7 in dark, and on a tone-0 base not at all. A base whose anchor is read back from its dark tone can meet it at either end. In glaze(280, 40), text with contrast: 12 on a tone-97 surface sits at 15.6 in light, but its dark tone reads back to 2.7, so a '-4' hover on it steps +2.3 in dark, not +3.5. A fixed or static base near the dark end meets it hardest, because it keeps its side while the dependent inverts: an auto '+20' on a static tone-10 base steps up to 30 in light, but its mirrored step in dark runs off the scale and stops at 9.25, next to the base. Add a contrast floor if the step has to survive, or make the dependent fixed or static too.
  • A base past the dark window. A contrast floor or an extreme on a base can push a base past the dark window’s edge, and a fixed delta can ride its base past it: a fixed '-10' chip on a tone-97 surface sits at 1.85 in dark, below the window’s 9.25. A step back toward the window moves by the full scaled delta; a step further out stops at the window’s edge or at the base, whichever is further out, instead of snapping back to the edge. An ink: { base: 'surface', tone: 'min' } on a tone-97 surface sits at tone 100 in dark, past the window’s 95.9: '+4' on it steps −3.5 there, and '-4' stays on ink.

High contrast’s windows are the full range. At the reference light eps the light one is the identity, so an auto base placed by a tone sits at its anchor there, light and dark agree on the flip, and the step fits. The first limit still applies under a custom light eps, and to a base read back from its dark tone: in glaze(280, 40), a '-8' hover on { base: 'surface', contrast: 3 } over a tone-40 surface steps −8 in light high contrast and +4.0 in dark high contrast.

Extremes in dark

tone: 'max' and tone: 'min' force a color to the end of the scheme’s tone scale without a magic number. 'max' is authored tone 100 and 'min' is 0, so without a base they go through the ordinary scheme mapping:

  • Under auto, they invert: 'max' is the lightest color in light (tone 100) and the darkest in dark (tone 9.25, the bottom of the dark window).
  • Under fixed, they keep their end: 'max' stays the lightest (tone 95.9 in dark).
  • Under static, they’re pinned to the absolute end in every scheme.

With a base, an extreme keeps its contrast with that base instead. Remapping the extreme through the narrower dark window would squeeze the span between the two, so Glaze measures the tone shift the light scheme applied between base and extreme, then replays it against the base’s dark tone — mirrored under auto, same-signed under fixed.

theme.ts

ts

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

const theme = glaze(0, 0);

theme.colors({
  bg: { tone: 60 },
  fg: { base: 'bg', tone: 'max' },
  top: { tone: 'max' },
  bottom: { tone: 'min' },
});
LightDark
bg
tone 62.0
tone 43.9
fg
tone 100.0Δ +38.03.18:1
tone 5.9Δ -38.03.18:1
top
tone 100.0
tone 9.25
bottom
tone 4.95
tone 95.9
fg replays the light shift (+38) as -38 in dark, keeping the same ratio. The root extremes top and bottom invert and land on the window edges.

The replayed tone is clamped to [0, 100] only, so it can cross the darkTone boundary — fg above lands at tone 5.9, below the dark window’s 9.25. That is intended: you asked for the extreme. When the shift doesn’t fit above or below the dark base, the color pins at tone 100 or 0. static extremes are unaffected. High-contrast variants follow the same rule, but their window is already the full range, so the replay matches the plain mapping unless the base itself sits asymmetrically across schemes.

Hue and saturation in dark

Tone is the only channel the scheme pipeline adapts. Hue and saturation enter it as authored, with two adjustments for the dark schemes.

Dark desaturation

Dark variants get a flat saturation reduction, set by darkDesaturation (default 0.1):

S_dark = S_light × (1 − darkDesaturation)

It applies to dark and darkContrast, skips static colors, and is bypassed entirely for a color that has an explicit dark saturation (below). Set darkDesaturation globally or per instance.

theme.ts

ts

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

const theme = glaze(280, 80);

theme.colors({
  accent: { tone: 55 },
  logo: { tone: 55, mode: 'static' },
});
LightDark
accent
h 280.0s 0.80
h 280.0s 0.72
logo
h 280.0s 0.80
h 280.0s 0.80
Seed saturation 80: accent loses 10% of its saturation in dark; the static logo keeps it.

Dark seed

When a uniform reduction isn’t enough — a hue that turns muddy on dark surfaces, an accent that stays loud however far you turn the global knob — give the theme a second seed for the dark schemes:

theme.ts

ts

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

const theme = glaze(
  {
    hue: 280,
    saturation: 80,
    darkHue: 250,
    darkSaturation: 50,
  },
);

theme.colors({
  accent: { tone: 55 },
  'accent-alt': { tone: 55, hue: '+20' },
  warning: { tone: 55, hue: 40 },
  link: { tone: 55, hue: '+20', darkHue: '+35' },
  highlight: { tone: 55, saturation: 0.9, darkSaturation: 0.6 },
  logo: { tone: 55, mode: 'static' },
});
LightDark
accent
h 280.0s 0.80
h 250.0s 0.50
accent-alt
h 300.0s 0.80
h 270.0s 0.50
warning
h 40.0s 0.80
h 40.0s 0.50
link
h 300.0s 0.80
h 285.0s 0.50
highlight
h 280.0s 0.72
h 250.0s 0.30
logo
h 280.0s 0.80
h 280.0s 0.80
Hue and saturation per scheme. Relative hues re-anchor to the dark seed, the absolute warning hue stays put, link and highlight override dark on their own, and the static logo ignores the dark seed.

How the dark channels resolve:

  • Both levels. darkHue / darkSaturation exist on the seed and on each color, and apply to dark and darkContrast. On the seed, darkSaturation is 0–100 like saturation; on a color it is a 0–1 factor of the dark seed saturation, like a color’s saturation.
  • Fallbacks. A missing dark value falls back to its light counterpart, and a color falls back to the seed. Omitting all of them reproduces the single-seed output exactly.
  • Relative hue follows the dark seed. darkHue: '+N' is relative to the dark seed hue, and so is a plain hue: '+N' with no darkHue, so a whole palette rotates together.
  • Absolute hue stays absolute. A numeric hue with no darkHue is used verbatim in dark.
  • No double reduction. Any explicit dark saturation, on the seed or the color, replaces darkDesaturation instead of stacking with it. The number you write is the number you get. A from color with neither saturation nor darkSaturation of its own is the exception: it starts from the literal’s saturation, ignores a seed-level darkSaturation, and still gets darkDesaturation. Give it a saturation and it reads the seed again, dark seed included.
  • static ignores both, and shadows, glows, and mixes have no channels of their own: they derive hue and saturation from the colors they reference, so they inherit dark overrides automatically.

With splitHue output, a dark seed makes the hue custom properties scheme-dependent, and Glaze re-declares them in the dark block — see Dark hues. The full field rules are in Color definitions; prefer tuning the seed over scattering per-color dark overrides, as Designing a palette explains.

High contrast

The lightContrast and darkContrast variants serve readers who ask for more contrast — by default through @media(prefers-contrast: more). They differ from the normal variants in three ways:

  1. No tone windows. Both windows are bypassed and the full [0, 100] range is available, so edge tones reach the edge and contrast floors have more room to solve. auto still inverts; fixed and static don’t.
  2. High-contrast pairs. Any value written as [normal, highContrast] takes its second entry.
  3. Escalated contrast floors. Unless you write an explicit high-contrast value, WCAG presets step up (AA → AAA, AA-large → AAA-large) and APCA targets gain 15 Lc. A bare WCAG number (4.5, { wcag: 6 }) and the AAA presets are not escalated; give them an explicit pair such as [4.5, 7]. See Contrast solving.
Lightmode: 'auto'
4.95
14.5
24.0
33.5
43.0
52.5
62.0
71.5
81.0
90.5
100
Light · high contrastmode: 'auto'
0
10
20
30
40
50
60
70
80
90
100
Darkmode: 'auto'
95.9
87.2
78.6
69.9
61.2
52.6
43.9
35.2
26.6
17.9
9.25
Dark · high contrastmode: 'auto'
100
90
80
70
60
50
40
30
20
10
0
Authored tones 0–100 in each scheme. The high-contrast rows use the full scale: auto still inverts, but nothing is trimmed.

High-contrast variants are always resolved. Whether they’re exported is up to output modes, which leave them out by default.

High-contrast pairs

HCPair<T> is either one value used in both tiers or an explicit [normal, highContrast] pair:

type HCPair<T> = T | [T, T];

It is accepted by tone, contrast, shadow and glow intensity, and mix value:

theme.colors({
  surface: { tone: 97 },
  // -8 in both tiers
  divider: { base: 'surface', tone: '-8' },
  // -8 normal, -16 high contrast
  border: { base: 'surface', tone: ['-8', '-16'] },
  // APCA preset: Lc 60, escalated to 75 in high contrast
  text: { base: 'surface', contrast: { apca: 'content' } },
  // Explicit pair: Lc 60 normal, Lc 90 high contrast
  'text-strong': {
    base: 'surface',
    contrast: { apca: ['content', 'preferred'] },
  },
});

For contrast, the pair can wrap the whole spec ([4.5, 7]) or sit inside the metric ({ wcag: [4.5, 7] }). An explicit high-contrast value turns off the automatic WCAG promotion or APCA boost for that color.

theme.ts

ts

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

const theme = glaze(280, 30);

theme.colors({
  surface: { tone: 97 },
  border: { base: 'surface', tone: ['-12', '-24'] },
  text: { base: 'surface', tone: '-45', contrast: 'AA' },
});
LightDarkLight HCDark HC
surface
tone 97.1
tone 11.9
tone 97.0
tone 3.0
border
tone 85.11.45:1
tone 22.21.37:1
tone 73.02.09:1
tone 27.02.06:1
text
tone 47.84.54:1
tone 61.74.55:1
tone 33.17.07:1
tone 67.57.07:1
The border doubles its step through an HC pair; the text's AA floor is promoted to AAA in high contrast.

High contrast is not a separate palette. Add pairs where readers should get more separation — text and content contrast, accent fills, borders, shadows and glows, decorative ramps that must stay perceivable — and leave the rest to the window bypass and the automatic escalation.

Manual contrast level

The two tiers assume contrast is a binary OS preference. When it is a setting your users control, set contrastLevel to a number from 0 to 100: the normal variants slide from the palette you authored (0) to its high-contrast resolution (100), and every level in between is resolved at that level. The default, 'auto', takes the authored normal entries as they are. The authoring work is the same: the high-contrast pairs you already wrote are what the slider ramps toward.

contrastLevel is a config field, so it works globally, per theme, per token, and through extend():

glaze.configure({ contrastLevel: 60 });
const theme = glaze(280, 80, { contrastLevel: 60 });
glaze.color('#26fcb2', { contrastLevel: 60 });

theme.ts

ts

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

const theme = glaze(280, 60);

theme.colors({
  surface: { tone: 97, saturation: 0.2 },
  border: {
    base: 'surface',
    tone: ['-12', '-24'],
    saturation: 0.3,
  },
  text: {
    base: 'surface',
    tone: '-45',
    contrast: 'AA',
    saturation: 0.5,
  },
  'accent-label': { tone: 100, mode: 'fixed' },
  accent: {
    base: 'accent-label',
    tone: '-30',
    contrast: { apca: ['content', 'body'] },
    mode: 'fixed',
  },
});

// Each column is this theme with a config override:
// { contrastLevel: 0 | 25 | 50 | 75 | 100 }
Light
AaGo
Level 0
text4.55:1border1.44:1labelLc 60.5
AaGo
Level 25
text5.18:1border1.58:1labelLc 64.3
AaGo
Level 50
text5.81:1border1.73:1labelLc 68.0
AaGo
Level 75
text6.44:1border1.90:1labelLc 71.8
AaGo
Level 100
text7.07:1border2.08:1labelLc 75.5
AaGo
HC tier
text7.07:1border2.08:1labelLc 75.5
Dark
AaGo
Level 0
text4.55:1border1.37:1labelLc 60.5
AaGo
Level 25
text5.18:1border1.51:1labelLc 64.3
AaGo
Level 50
text5.81:1border1.68:1labelLc 68.0
AaGo
Level 75
text6.44:1border1.86:1labelLc 71.8
AaGo
Level 100
text7.07:1border2.07:1labelLc 75.5
AaGo
HC tier
text7.07:1border2.07:1labelLc 75.5
The normal light and dark variants at five levels. Under each: the measured WCAG ratio of text and border against the surface, and the APCA Lc of the accent label on its fill, whose floor ramps from 'content' (Lc 60) to 'body' (Lc 75). Level 0 is the authored palette; level 100 equals the theme's high-contrast tier (last column).

What the level moves:

  • Authored pairs — tone, contrast, intensity, and mix value pairs are interpolated between their two entries.
  • The tone windows — each endpoint walks toward the full range: light 10 → 0, dark 15 → 0 and 95 → 100.
  • Contrast escalation — the normal and escalated targets are interpolated, so AA at level 50 solves for 5.75:1.

The interpolated values go through the ordinary resolve, so a contrast floor is solved at every level, not approximated. Hue, saturation, darkDesaturation, opacity, and pastel don’t change with the level.

Shadows and glows are solved at every level too, but the widening windows move a page toward black or white faster than an interpolated intensity fades. A shadow with intensity: [8, 0] on a white page asks for 4 tones at level 50, where the dark page sits at about tone 3.15. Such a step pins to its strongest composite, as it would anywhere, and warns only when it is out of reach in light and dark alike; see When Glaze moves the surface.

Level 0 reproduces 'auto' and level 100 reproduces the high-contrast output, bit for bit. The high-contrast tier itself ignores the level: lightContrast / darkContrast stay the true high-contrast resolution, so a prefers-contrast: more block still escalates on top of the reader’s chosen level. At a global level of 100 the tier would duplicate the normal set, so it is dropped.

A pair with no midpoint switches from its normal entry to its high-contrast one at level 50: one that mixes kinds ([50, 'max'], [50, '+20']), or any pair of extremes (['max', 'min']), which have no magnitude to blend. A contrast-solved color prefers the side of its base that the nearer endpoint (level 0 or 100) resolves to, so its preferred side switches only at level 50. At any level where the preferred side can’t reach the target, the color falls back to the other side. The full rules are in Manual contrast level.

Because configure() invalidates every theme cache, a runtime slider only needs to re-export:

slider.oninput = () => {
  glaze.configure({ contrastLevel: slider.valueAsNumber });
  apply(theme.css());
};

contrastLevel: 0 is the natural rest position for a slider you ship but default off. configure() never clears a field by omission, so pass contrastLevel: 'auto' to leave manual mode. How .export() treats a level, and what happens when a dependent and its base sit at different levels, is in Clearing it, exporting it, base links.

Output modes

Resolution always produces four variants; modes decides which ones an export includes:

// Light only
theme.tokens({ modes: { dark: false } });

// Light + dark (the default)
theme.tokens();

// All four
theme.tokens({ modes: { dark: true, highContrast: true } });
// → { light, dark, lightContrast, darkContrast }

modes is accepted by tokens(), tasty(), json(), dtcg(), dtcgResolver(), and tailwind(), on themes and palettes alike. css() has no modes option: it always returns all four declaration blocks. A per-call modes overrides glaze.configure({ modes }), and a global contrastLevel of 100 drops the high-contrast tier even when modes asks for it; the full precedence is in Output modes.

A third flag, hdr, is off by default and is only for HDR glows.

The selectors that switch variants in Tasty output come from states.dark and states.highContrast (by default the prefers-color-scheme: dark and prefers-contrast: more media queries). How each export lays out its variants is in Output formats and Exporting and integration.