Generate perceptually balanced design system themes from a single seed color — zero dependencies.
@panmdaa/colors is a TypeScript library for color science and accessible design system generation. Built on the HCT (Hue-Chroma-Tone) color space, it creates complete light and dark palettes with guaranteed WCAG-compliant contrast from any seed color.
npm install @panmdaa/colors
import { palette, contrastChecker, simulateCVD } from "@panmdaa/colors";
// Generate a full design system theme from a seed color
const theme = palette("#744c9d", { variant: "expressive" });
theme.light.primary; // "#b091ce"
theme.dark.primary; // "#dcb8ff"
theme.light.background; // "#fcfcff"
// Score any color pair 0–10 with WCAG thresholds + CVD simulation
contrastChecker("#ffffff", "#ff0000").score; // 4.6
contrastChecker("#ffffff", "#ff0000").simulations.deuteranopia.score; // 3.8
// Simulate color blindness (Machado 2009 — same as Chrome DevTools)
simulateCVD("#ff0000", "protanopia"); // "#665900"
simulateCVD("#ff0000", "deuteranopia"); // "#998700"
// Check every on-* pair in a palette with a single call
paletteChecker(theme).summary; // { total: 30, passingAA: 30, ... }10 theme variants for both light and dark:
import { palette } from "@panmdaa/colors";
// All variants
palette("#744c9d", { variant: "monochrome" }); // Grayscale
palette("#744c9d", { variant: "neutral" }); // Muted, neutral
palette("#744c9d", { variant: "tonal-spot" }); // Default — subtle, balanced tint
palette("#744c9d", { variant: "vibrant" }); // High chroma
palette("#744c9d", { variant: "expressive" }); // Rotated hues
palette("#744c9d", { variant: "fidelity" }); // Source color faithful
palette("#744c9d", { variant: "content" }); // Content-based
palette("#744c9d", { variant: "rainbow" }); // Rainbow spectrum
palette("#744c9d", { variant: "fruit-salad" }); // Colorful, playful
palette("#744c9d", { variant: "cmf" }); // Custom configurable variantEach returns a Theme with light and dark palettes of 53 color roles:
const theme = palette("#744c9d", { variant: "tonal-spot" });
theme.light.primary; // Primary brand color
theme.light["primary-dim"]; // Dimmed variant
theme.light["on-primary"]; // Text/icon on primary
theme.light["primary-container"]; // Primary container surface
theme.light["on-primary-container"];
theme.light["primary-fixed"];
theme.light["primary-fixed-dim"];
theme.light["on-primary-fixed"];
theme.light["on-primary-fixed-variant"];
theme.light.background;
theme.light.surface;
theme.light["surface-dim"];
theme.light["surface-bright"];
theme.light["surface-container-lowest"];
theme.light["surface-container-low"];
theme.light["surface-container"];
theme.light["surface-container-high"];
theme.light["surface-container-highest"];
theme.light["surface-variant"];
theme.light["on-surface"];
theme.light["on-surface-variant"];
theme.light.outline;
theme.light["outline-variant"];
theme.light.error;
theme.light["error-dim"];
theme.light["on-error"];
theme.light.shadow;
theme.light.scrim;
theme.light["surface-tint"];
theme.light["inverse-surface"];
theme.light["inverse-on-surface"];
theme.light["inverse-primary"];
// + secondary, tertiary with all their dim/container/fixed variantsExtend the palette with your own tokens. Perfect for brand colors, accents, and design tokens:
const theme = palette("#6750a4", {
variant: "tonal-spot",
extraColors: {
brand: "#ff6600", // direct color
muted: { from: "primary" }, // copy from palette
mutedBold: { from: "primary", adjust: { tone: -10 } }, // adjusted
accent: { harmonize: "#ff0000" }, // harmonized with source
random: { random: true }, // randomized near source
duo: { mix: ["primary", "secondary"] }, // blend of palette keys
sunset: { mix: ["#ff0000", "#ff8800"] }, // blend of colors
},
});
theme.light.brand; // "#ff6600"
theme.light["on-brand"]; // "#ffffff" — auto-generated foreground
theme.light.muted; // matches primary
theme.light.mutedBold; // primary, tone-10
theme.light.duo; // HCT midpoint of primary + secondary
theme.light.sunset; // HCT midpoint of red + orangeKey behaviour:
- Names normalize to kebab-case:
theme.light["my-color"] - Every non-
on-*token gets an auto-generatedon-{name}with ≥4.5:1 contrast from-based tokens (includingmixwith palette keys) resolve per-mode (different in light/dark)harmonize,random, and direct hex values are shared across modes
Standalone HCT interpolation or inline in your palette:
import { gradient } from "@panmdaa/colors";
// 5-step gradient from red to blue
const steps = gradient("#ff0000", "#0000ff", 5);
steps[0]; // "#ff0000"
steps[2]; // midpoint (interpolated in HCT space)
steps[4]; // "#0000ff"
// Inline in extraColors — expands to {name}-N tokens
const theme = palette("#6750a4", {
extraColors: {
sunset: { gradient: { from: "#ff0000", to: "#0000ff", count: 5 } },
ramp: { gradient: { from: "primary", to: "secondary", count: 3 } },
},
});
theme.light["sunset-0"]; // first step of the gradient
theme.light["sunset-4"]; // last step
theme.light["on-sunset-0"]; // auto-generated foreground
theme.light["ramp-0"]; // gradient between light primary → secondaryfrom and to accept hex colors or palette key references ("primary", "secondary", etc.).
Turn any theme into CSS custom properties:
import { palette, generateCSS, generateCSSSheet } from "@panmdaa/colors";
const theme = palette("#6750a4", {
extraColors: { brand: "#ff6600" },
});
// Quick CSS string
const css = generateCSS(theme);
// :root { --color-primary: #b091ce; --color-on-primary: ... }
// Full stylesheet with light/dark blocks
const sheet = generateCSSSheet(theme);
// :root { --color-primary: ... }
// @media (prefers-color-scheme: dark) { :root { ... } }
// Custom prefix and dark selector
generateCSSSheet(theme, {
prefix: "--md-sys-",
darkSelector: '[data-theme="dark"]',
});
// :root { --md-sys-primary: ... }
// [data-theme="dark"] { ... }Audit all foreground/background pairs in your theme:
import { palette, report } from "@panmdaa/colors";
const theme = palette("#6750a4");
const { pairs, summary } = report(theme);
summary;
// { total: 14, passingAA: 14, passingAALarge: 14, passingAAA: 12 }
// Works for dark mode too
report(theme, "dark");
// Each pair includes detailed info
pairs[0];
// { role: "primary", onRole: "on-primary", fg: "#b091ce", bg: "#1e192b", ratio: 11.2, AA: true, AALarge: true, AAA: true }
// Automatically includes extraColors tokens
const theme2 = palette("#6750a4", {
extraColors: { brand: { from: "primary" } },
});
report(theme2).pairs.some(p => p.role === "brand"); // trueScore any color pair from 0–10 with WCAG thresholds and optional color vision deficiency simulation:
import { contrastChecker } from "@panmdaa/colors";
// Single pair — includes CVD simulations by default
const score = contrastChecker("#ffffff", "#ff0000");
// { ratio: 4.0, score: 4.6, smallText: 4.3, largeText: 6.3 }
// score.simulations.protanopia → { ratio: 6.4, score: 6.1, ... }
// score.simulations.deuteranopia → { ratio: 3.2, score: 3.8, ... }
// score.simulations.tritanopia → { ratio: 4.0, score: 4.5, ... }
// Without CVD — returns plain ContrastScore
contrastChecker("#ffffff", "#ff0000", false);
// { ratio: 4.0, score: 4.6, smallText: 4.3, largeText: 6.3 }Three scores per result:
| Field | Range | Thresholds |
|---|---|---|
score |
0–10 | Logarithmic scale: log2(ratio) / log2(21) × 10 |
smallText |
0–10 | AA (4.5:1) = 5, AAA (7:1) = 7 |
largeText |
0–10 | AA (3:1) = 5, AAA (4.5:1) = 7 |
Under the hood, CVD simulations use the Machado (2009) physiologically-based model — the same algorithm Chrome DevTools uses for "Emulate vision deficiencies". Both base and on colors are simulated before computing the contrast ratio.
Check every on-* pair in a generated palette with a single call — works with the full theme or a single mode:
import { palette, paletteChecker } from "@panmdaa/colors";
const pal = palette("#6750a4", {
variant: "tonal-spot",
extraColors: { warning: "#ff8800" },
});
// Full theme — light + dark + global summary
const check = paletteChecker(pal);
check.summary;
// { total: 30, passingAA: 30, passingAALarge: 30, passingAAA: 8 }
check.light.summary;
// { total: 15, passingAA: 15, passingAALarge: 15, passingAAA: 4 }
// Each pair has WCAG booleans and CVD simulations (by default)
check.light.pairs[0];
// { role: "primary", onRole: "on-primary", base: "#b091ce", on: "#1e192b",
// score: { ratio: 6.04, ... }, AA: true, AALarge: true, AAA: false,
// simulations: { protanopia: { base: "...", on: "...", score: {...}, AA: true, ... }, ... } }
// Single mode — pass light or dark directly
const lightCheck = paletteChecker(pal.light);
lightCheck.summary; // { total: 15, ... }
// Disable CVD simulations
const plain = paletteChecker(pal, false);Each CVD simulation includes the simulated colors so you can see exactly what shifts:
const deuteranopia = check.light.pairs[0].simulations.deuteranopia;
deuteranopia.base; // "#3f527b" — simulated background
deuteranopia.on; // "#dae3fe" — simulated foreground
deuteranopia.AA; // true — still passesSimulate how any color appears under the most common color vision deficiencies:
import { simulateCVD, simulateAllCVD } from "@panmdaa/colors";
// Single simulation
simulateCVD("#ff0000", "protanopia"); // "#665900" (red → brown)
simulateCVD("#ff0000", "deuteranopia"); // "#998700" (red → olive)
simulateCVD("#ff0000", "tritanopia"); // "#ff000e" (nearly unchanged)
// All three at once
simulateAllCVD("#ff0000");
// { protanopia: "#665900", deuteranopia: "#998700", tritanopia: "#ff000e" }
// Partial deficiency (anomalous trichromacy)
simulateCVD("#ff0000", "protanopia", "mild"); // "#a95000"
// White is unaffected (no chroma to lose)
simulateCVD("#ffffff", "protanopia"); // "#ffffff"The simulation uses the Machado (2009) physiologically-based model with matrices from Machado (2010) — the same algorithm Chrome DevTools uses internally. The pipeline is sRGB → linear RGB → matrix transform → sRGB, ensuring physically accurate results.
| Type | Affected cones | Common colors confused |
|---|---|---|
protanopia |
Red (L) | Red-green, blue-violet |
deuteranopia |
Green (M) | Red-green, rose-green |
tritanopia |
Blue (S) | Blue-yellow, green-cyan |
Edit a color's HCT channels independently:
import {
getHue, getChroma, getTone,
setHue, setChroma, setTone,
rotateHue, lighten, darken,
saturate, desaturate, edit,
tone, tones,
} from "@panmdaa/colors";
// Per-channel getters
getHue("#744c9d"); // 283
getChroma("#744c9d"); // 36
getTone("#744c9d"); // 62
// Per-channel setters (returns new color)
setHue("#744c9d", 200); // shift hue
setChroma("#744c9d", 50); // increase saturation
setTone("#744c9d", 80); // lighten
// Convenience functions
lighten("#744c9d", 10); // +10 tone
darken("#744c9d", 10); // -10 tone
saturate("#744c9d", 20); // +20 chroma
desaturate("#744c9d", 20); // -20 chroma
rotateHue("#744c9d", 90); // +90 hue
// Batch edit
edit("#744c9d", { hue: 200, chroma: 40, tone: 70 });
// Foreground / background contrast pairing
onColor("#000000"); // foreground for dark background
underColor("#ffffff"); // background for light foreground
// Get a specific tone
tone("#744c9d", 90); // same hue/chroma, tone 90
// All reference tones at once
const ts = tones("#744c9d");
ts[50]; // tone 50 at source color's hue/chromaimport { mix } from "@panmdaa/colors";
// Blend two colors — perceptual midpoint in HCT space
mix("#ff0000", "#0000ff"); // hue ~283 (purple)
// Blend any number of colors
mix("#ff0000", "#00ff00", "#0000ff"); // three-way blend
// All inputs weighted equally, hue is circular-averaged
mix("#ff0000", "#ff0000", "#0000ff");
// ≈ mix("#ff0000", "#0000ff") with extra red weightThe mix function operates in HCT space — hue is circular-averaged (handles the
0°/360° wrap), chroma and tone are arithmetically averaged. The result is
perceptually uniform, unlike naive RGB blending.
import { harmonize, fixDisliked, isDisliked } from "@panmdaa/colors";
// Harmonize a color to complement another
harmonize("#ff0000", "#744c9d"); // shifts design color toward source
// Fix disliked colors (yellow-green, etc.)
isDisliked("#4a7a3f"); // true
fixDisliked("#4a7a3f"); // shifted to avoid the disliked zoneimport { hct, fromHct } from "@panmdaa/colors";
const color = hct("#744c9d");
color.hue; // 283
color.chroma; // 36
color.tone; // 62
// Create a color from HCT values
fromHct(283, 36, 80); // "#c9aae0" (same hue/chroma, tone 80)
// Convert between formats
toNumber("#744c9d"); // 7629981 (ARGB int)
fromNumber(7629981); // "#744c9d"import { fromImage } from "@panmdaa/colors";
const seed = await fromImage(imageElement); // extracts dominant color
const theme = palette(seed, { variant: "expressive" });| Function | Description |
|---|---|
palette(color, options?) |
Generate light + dark theme (variant, extraColors, gradients) |
onColor(bg, ratio?) |
Foreground with ≥4.5:1 contrast at same hue/chroma |
underColor(fg, ratio?) |
Background with ≥4.5:1 contrast (inverse of onColor) |
mix(...colors) |
Blend N colors together in HCT space |
gradient(from, to, count) |
HCT-interpolated steps between two colors |
generateCSS(theme, options?) |
CSS custom properties string |
generateCSSSheet(theme, options?) |
Full stylesheet with light/dark blocks |
report(theme, mode?) |
WCAG contrast report for all on-* pairs |
contrastChecker(base, on, cvd?) |
Contrast score 0–10 with optional CVD simulation |
paletteChecker(theme/palette, cvd?) |
Check all on-* pairs with scores, WCAG flags, and CVD |
simulateCVD(color, deficiency, severity?) |
Simulate color under protanopia/deuteranopia/tritanopia |
simulateAllCVD(color, severity?) |
Simulate color under all three deficiencies at once |
toNumber(color) |
#rrggbb → ARGB integer |
fromNumber(color) |
ARGB integer → #rrggbb |
hct(color) |
#rrggbb → HCT color object |
fromHct(hue, chroma, tone) |
HCT values → #rrggbb |
getHue / getChroma / getTone |
Read a single HCT channel |
setHue / setChroma / setTone |
Set a single HCT channel |
lighten / darken |
Adjust tone |
saturate / desaturate |
Adjust chroma |
rotateHue |
Rotate hue |
edit(color, { hue?, chroma?, tone? }) |
Batch channel edit |
tone(color, tone) |
Get color at a specific tone |
tones(color) |
All 14 reference tones at once |
harmonize(design, source) |
Shift a color toward a source |
fixDisliked(color) |
Fix disliked hue |
isDisliked(color) |
Check if color is in the disliked zone |
fromImage(image) |
Extract dominant color from an <img> |
@panmdaa/colors originated from Google's Material Color Utilities project and preserves its underlying color science (HCT, CAM16, dynamic color algorithms, quantization, etc.).
Over time, the implementation has been substantially refactored and evolved. Legacy compatibility layers, version-specific branches, and internal abstractions were removed in favor of a unified architecture with a stable, developer-oriented API.
Today, @panmdaa/colors is developed independently as part of the Panmdaa ecosystem while remaining compatible with the Material Design color model where appropriate.
Key differentiators:
@panmdaa/colorsis the only library that combines HCT color science, full design system theme generation, custom token extensibility, contrast scores with CVD simulation, palette-wide accessibility checks, and image quantization in a single tree-shakeable zero-dependency package.- Material Color Utilities is Google's reference implementation. Its API is designed for internal Material Design usage and lacks ergonomic utilities like
palette(),onColor(),report(), or CSS string generation. - Culori and Chroma.js are general-purpose color manipulation libraries with excellent interpolation, but they don't generate design-system themes from a seed color.
- Radix Colors provides well-crafted light/dark scales for UI but doesn't handle HCT, dynamic theme generation, or programmatic color science.
src/
├── hct/ ← HCT color space (CAM16, viewing conditions)
├── palette/ ← TonalPalette (hue + chroma → tones)
├── scheme/ ← DynamicScheme, DynamicColor, 10 variants
├── spec/ ← Token definitions, palette specs, color calculation
├── science/ ← Blend, dislike analyzer, color blindness, temperature, score
├── quantize/ ← Image quantization (Wu, Celebi)
└── utils/ ← Color/math/string utilities
Built on proven color science, spec version 2026.
npm run |
Description |
|---|---|
build |
Bundle with tsup (ESM + DTS) |
test |
Run 80+ color correctness tests |
typecheck |
TypeScript strict check |
lint |
Biome lint |
format |
Biome format |
Crafted with ❤️ by the Panmdaa project.