OKHSL-based color theme generator with WCAG contrast solving
Glaze generates robust light, dark, and high-contrast color schemes from a single hue/saturation seed. It preserves WCAG contrast ratios for UI color pairs via explicit dependency declarations — no hidden role math, no magic multipliers.
- OKHSL color space — perceptually uniform hue and saturation
- WCAG 2 contrast solving — automatic lightness adjustment to meet AA/AAA targets
- Light + Dark + High-Contrast — all schemes from one definition
- Per-color hue override — absolute or relative hue shifts within a theme
- Multi-format output —
okhsl,rgb,hsl,oklch - Import/Export — serialize and restore theme configurations
- Create from hex/RGB — start from an existing brand color
- Zero dependencies — pure math, runs anywhere (Node.js, browser, edge)
- Tree-shakeable ESM + CJS — dual-format package
- TypeScript-first — full type definitions included
pnpm add @tenphi/glazenpm install @tenphi/glazeyarn add @tenphi/glazeimport { glaze } from '@tenphi/glaze';
// Create a theme from a hue (0–360) and saturation (0–100)
const primary = glaze(280, 80);
// Define colors with explicit lightness and contrast relationships
primary.colors({
surface: { lightness: 97, saturation: 0.75 },
text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
border: { base: 'surface', lightness: ['-7', '-20'], contrast: 'AA-large' },
'accent-fill': { lightness: 52, mode: 'fixed' },
'accent-text': { base: 'accent-fill', lightness: '+48', contrast: 'AA', mode: 'fixed' },
});
// Create status themes by rotating the hue
const danger = primary.extend({ hue: 23 });
const success = primary.extend({ hue: 157 });
// Compose into a palette and export
const palette = glaze.palette({ primary, danger, success });
const tokens = palette.tokens({ prefix: true });
// → { '#primary-surface': { '': 'okhsl(...)', '@dark': 'okhsl(...)' }, ... }A single glaze theme is tied to one hue/saturation seed. Status colors (danger, success, warning) are derived via extend, which inherits all color definitions and replaces the seed.
Individual colors can override the hue via the hue prop (see Per-Color Hue Override), but the primary purpose of a theme is to scope colors with the same hue.
Every color is defined explicitly. No implicit roles — every value is stated.
primary.colors({
surface: { lightness: 97, saturation: 0.75 },
border: { lightness: 90, saturation: 0.20 },
});lightness— lightness in the light scheme (0–100)saturation— saturation factor applied to the seed saturation (0–1, default:1)
primary.colors({
surface: { lightness: 97, saturation: 0.75 },
text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
});base— name of another color in the same themelightness— position of this color (see Lightness Values)contrast— ensures the WCAG contrast ratio meets a target floor against the base
The lightness prop accepts two forms:
| Form | Example | Meaning |
|---|---|---|
| Number (absolute) | lightness: 45 |
Absolute lightness 0–100 |
| String (relative) | lightness: '-52' |
Relative to base color's lightness |
Absolute lightness on a dependent color (with base) positions the color independently. In dark mode, it is dark-mapped on its own. The contrast WCAG solver acts as a safety net.
Relative lightness applies a signed delta to the base color's resolved lightness. In dark mode with auto adaptation, the sign flips automatically.
// Relative: 97 - 52 = 45 in light mode
'text': { base: 'surface', lightness: '-52' }
// Absolute: lightness 45 in light mode, dark-mapped independently
'text': { base: 'surface', lightness: 45 }A dependent color with base but no lightness inherits the base's lightness (equivalent to a delta of 0).
Individual colors can override the theme's hue. The hue prop accepts:
| Form | Example | Meaning |
|---|---|---|
| Number (absolute) | hue: 120 |
Absolute hue 0–360 |
| String (relative) | hue: '+20' |
Relative to the theme seed hue |
Important: Relative hue is always relative to the theme seed hue, not to a base color's hue.
const theme = glaze(280, 80);
theme.colors({
surface: { lightness: 97 },
// Gradient end — slight hue shift from seed (280 + 20 = 300)
gradientEnd: { lightness: 90, hue: '+20' },
// Entirely different hue
warning: { lightness: 60, hue: 40 },
});Ensures the WCAG contrast ratio meets a target floor. Accepts a numeric ratio or a preset string:
type MinContrast = number | 'AA' | 'AAA' | 'AA-large' | 'AAA-large';| Preset | Ratio |
|---|---|
'AA' |
4.5 |
'AAA' |
7 |
'AA-large' |
3 |
'AAA-large' |
4.5 |
You can also pass any numeric ratio directly (e.g., contrast: 4.5, contrast: 7, contrast: 11).
The constraint is applied independently for each scheme. If the lightness already satisfies the floor, it's kept. Otherwise, the solver adjusts lightness until the target is met.
lightness and contrast accept a [normal, high-contrast] pair:
'border': { base: 'surface', lightness: ['-7', '-20'], contrast: 'AA-large' }
// ↑ ↑
// normal high-contrastA single value applies to both modes. All control is local and explicit.
'text': { base: 'surface', lightness: '-52', contrast: 'AAA' }
'border': { base: 'surface', lightness: ['-7', '-20'], contrast: 'AA-large' }
'muted': { base: 'surface', lightness: ['-35', '-50'], contrast: ['AA-large', 'AA'] }.colors(defs) performs an additive merge — it adds new colors and overwrites existing ones by name, but does not remove other colors:
const theme = glaze(280, 80);
theme.colors({ surface: { lightness: 97 } });
theme.colors({ text: { lightness: 30 } });
// Both 'surface' and 'text' are now defined.color(name) returns the definition, .color(name, def) sets it:
theme.color('surface', { lightness: 97, saturation: 0.75 }); // set
const def = theme.color('surface'); // get → { lightness: 97, saturation: 0.75 }.remove(name) or .remove([name1, name2]) deletes color definitions:
theme.remove('surface');
theme.remove(['text', 'border']);theme.has('surface'); // → true/false
theme.list(); // → ['surface', 'text', 'border', ...]theme.reset(); // removes all color definitionsSerialize a theme's configuration (hue, saturation, color definitions) to a plain JSON-safe object, and restore it later:
// Export
const snapshot = theme.export();
// → { hue: 280, saturation: 80, colors: { surface: { lightness: 97, saturation: 0.75 }, ... } }
const jsonString = JSON.stringify(snapshot);
// Import
const restored = glaze.from(JSON.parse(jsonString));
// restored is a fully functional GlazeThemeThe export contains only the configuration — not resolved color values. Resolved values are recomputed on demand.
Create a single color token without a full theme:
const accent = glaze.color({ hue: 280, saturation: 80, lightness: 52, mode: 'fixed' });
accent.resolve(); // → ResolvedColor with light/dark/lightContrast/darkContrast
accent.token(); // → { '': 'okhsl(...)', '@dark': 'okhsl(...)' }
accent.json(); // → { light: 'okhsl(...)', dark: 'okhsl(...)' }Standalone colors are always root colors (no base/contrast).
Create a theme from an existing brand color by extracting its OKHSL hue and saturation:
// From hex
const brand = glaze.fromHex('#7a4dbf');
// From RGB (0–255)
const brand = glaze.fromRgb(122, 77, 191);The resulting theme has the extracted hue and saturation. Add colors as usual:
brand.colors({
surface: { lightness: 97, saturation: 0.75 },
text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
});Control the color format in exports with the format option:
// Default: OKHSL
theme.tokens(); // → 'okhsl(280.0 60.0% 97.0%)'
// RGB with fractional precision
theme.tokens({ format: 'rgb' }); // → 'rgb(244.123, 240.456, 249.789)'
// HSL
theme.tokens({ format: 'hsl' }); // → 'hsl(270.5, 45.2%, 95.8%)'
// OKLCH
theme.tokens({ format: 'oklch' }); // → 'oklch(96.5% 0.0123 280.0)'The format option works on all export methods: theme.tokens(), theme.json(), palette.tokens(), palette.json(), and standalone glaze.color().token() / .json().
Available formats:
| Format | Output | Notes |
|---|---|---|
'okhsl' (default) |
okhsl(H S% L%) |
Native format, perceptually uniform |
'rgb' |
rgb(R, G, B) |
Fractional 0–255 values (3 decimals) |
'hsl' |
hsl(H, S%, L%) |
Standard CSS HSL |
'oklch' |
oklch(L% C H) |
OKLab-based LCH |
Modes control how colors adapt across schemes:
| Mode | Behavior |
|---|---|
'auto' (default) |
Full adaptation. Light ↔ dark inversion. High-contrast boost. |
'fixed' |
Color stays recognizable. Only safety corrections. For brand buttons, CTAs. |
'static' |
No adaptation. Same value in every scheme. |
auto mode — relative lightness sign flips in dark scheme:
// Light: surface L=97, text lightness='-52' → L=45 (dark text on light bg)
// Dark: surface inverts to L≈14, sign flips → L=14+52=66
// contrast solver may push further (light text on dark bg)fixed mode — lightness is mapped (not inverted), relative sign preserved:
// Light: accent-fill L=52, accent-text lightness='+48' → L=100 (white on brand)
// Dark: accent-fill maps to L≈51.6, sign preserved → L≈99.6static mode — no adaptation, same value in every scheme.
auto — inverted within the configured window:
const [lo, hi] = darkLightness; // default: [10, 90]
const invertedL = ((100 - lightness) * (hi - lo)) / 100 + lo;fixed — mapped without inversion:
const mappedL = (lightness * (hi - lo)) / 100 + lo;| Color | Light L | Auto (inverted) | Fixed (mapped) |
|---|---|---|---|
| surface (L=97) | 97 | 12.4 | 87.6 |
| accent-fill (L=52) | 52 | 48.4 | 51.6 |
| accent-text (L=100) | 100 | 10 | 90 |
darkDesaturation reduces saturation for all colors in dark scheme:
S_dark = S_light * (1 - darkDesaturation) // default: 0.1extend creates a new theme inheriting all color definitions, replacing the hue and/or saturation seed:
const primary = glaze(280, 80);
primary.colors({ /* ... */ });
const danger = primary.extend({ hue: 23 });
const success = primary.extend({ hue: 157 });
const warning = primary.extend({ hue: 84 });Override individual colors (additive merge):
const danger = primary.extend({
hue: 23,
colors: { 'accent-fill': { lightness: 48, mode: 'fixed' } },
});Combine multiple themes into a single palette:
const palette = glaze.palette({ primary, danger, success, warning });const tokens = palette.tokens({ prefix: true });
// → {
// '#primary-surface': { '': 'okhsl(...)', '@dark': 'okhsl(...)' },
// '#danger-surface': { '': 'okhsl(...)', '@dark': 'okhsl(...)' },
// }Custom prefix mapping:
palette.tokens({ prefix: { primary: 'brand-', danger: 'error-' } });const data = palette.json({ prefix: true });
// → {
// primary: { surface: { light: 'okhsl(...)', dark: 'okhsl(...)' } },
// danger: { surface: { light: 'okhsl(...)', dark: 'okhsl(...)' } },
// }Control which scheme variants appear in exports:
// Light only
palette.tokens({ modes: { dark: false, highContrast: false } });
// Light + dark (default)
palette.tokens({ modes: { highContrast: false } });
// All four variants
palette.tokens({ modes: { dark: true, highContrast: true } });Resolution priority (highest first):
tokens({ modes })/json({ modes })— per-call overrideglaze.configure({ modes })— global config- Built-in default:
{ dark: true, highContrast: false }
glaze.configure({
darkLightness: [10, 90], // Dark scheme lightness window [lo, hi]
darkDesaturation: 0.1, // Saturation reduction in dark scheme (0–1)
states: {
dark: '@dark', // State alias for dark mode tokens
highContrast: '@high-contrast',
},
modes: {
dark: true, // Include dark variants in exports
highContrast: false, // Include high-contrast variants
},
});type RelativeValue = `+${number}` | `-${number}`;
type HCPair<T> = T | [T, T]; // [normal, high-contrast]
interface ColorDef {
// Lightness
lightness?: HCPair<number | RelativeValue>;
// Number: absolute (0–100)
// String: relative to base ('+N' / '-N')
// Hue override
hue?: number | RelativeValue;
// Number: absolute (0–360)
// String: relative to theme seed ('+N' / '-N')
// Saturation factor (0–1, default: 1)
saturation?: number;
// Dependency
base?: string; // name of another color
contrast?: HCPair<MinContrast>; // WCAG contrast ratio floor against base
// Adaptation mode
mode?: 'auto' | 'fixed' | 'static'; // default: 'auto'
}A root color must have absolute lightness (a number). A dependent color must have base. Relative lightness (a string) requires base.
| Condition | Behavior |
|---|---|
Both absolute lightness and base on same color |
Warning, lightness takes precedence |
contrast without base |
Validation error |
Relative lightness without base |
Validation error |
lightness resolves outside 0–100 |
Clamp silently |
saturation outside 0–1 |
Clamp silently |
Circular base references |
Validation error |
base references non-existent name |
Validation error |
Glaze re-exports its internal color math for advanced use:
import {
okhslToLinearSrgb,
okhslToSrgb,
okhslToOklab,
srgbToOkhsl,
parseHex,
relativeLuminanceFromLinearRgb,
contrastRatioFromLuminance,
formatOkhsl,
formatRgb,
formatHsl,
formatOklch,
findLightnessForContrast,
resolveMinContrast,
} from '@tenphi/glaze';import { glaze } from '@tenphi/glaze';
const primary = glaze(280, 80);
primary.colors({
surface: { lightness: 97, saturation: 0.75 },
text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
border: { base: 'surface', lightness: ['-7', '-20'], contrast: 'AA-large' },
bg: { lightness: 97, saturation: 0.75 },
icon: { lightness: 60, saturation: 0.94 },
'accent-fill': { lightness: 52, mode: 'fixed' },
'accent-text': { base: 'accent-fill', lightness: '+48', contrast: 'AA', mode: 'fixed' },
disabled: { lightness: 81, saturation: 0.4 },
});
const danger = primary.extend({ hue: 23 });
const success = primary.extend({ hue: 157 });
const warning = primary.extend({ hue: 84 });
const note = primary.extend({ hue: 302 });
const palette = glaze.palette({ primary, danger, success, warning, note });
// Export as OKHSL tokens (default)
const tokens = palette.tokens({ prefix: true });
// Export as RGB for broader CSS compatibility
const rgbTokens = palette.tokens({ prefix: true, format: 'rgb' });
// Save and restore a theme
const snapshot = primary.export();
const restored = glaze.from(snapshot);
// Create from an existing brand color
const brand = glaze.fromHex('#7a4dbf');
brand.colors({ surface: { lightness: 97 }, text: { base: 'surface', lightness: '-52' } });| Method | Description |
|---|---|
glaze(hue, saturation?) |
Create a theme from hue (0–360) and saturation (0–100) |
glaze({ hue, saturation }) |
Create a theme from an options object |
glaze.from(data) |
Create a theme from an exported configuration |
glaze.fromHex(hex) |
Create a theme from a hex color (#rgb or #rrggbb) |
glaze.fromRgb(r, g, b) |
Create a theme from RGB values (0–255) |
glaze.color(input) |
Create a standalone color token |
| Method | Description |
|---|---|
theme.colors(defs) |
Add/replace colors (additive merge) |
theme.color(name) |
Get a color definition |
theme.color(name, def) |
Set a single color definition |
theme.remove(names) |
Remove one or more colors |
theme.has(name) |
Check if a color is defined |
theme.list() |
List all defined color names |
theme.reset() |
Clear all color definitions |
theme.export() |
Export configuration as JSON-safe object |
theme.extend(options) |
Create a child theme |
theme.resolve() |
Resolve all colors |
theme.tokens(options?) |
Export as token map |
theme.json(options?) |
Export as plain JSON |
| Method | Description |
|---|---|
glaze.configure(config) |
Set global configuration |
glaze.palette(themes) |
Compose themes into a palette |
glaze.getConfig() |
Get current global config |
glaze.resetConfig() |
Reset to defaults |