Dynamic Color Generation: Algorithms for Design Systems
Embed This Widget
Add the script tag and a data attribute to embed this widget.
Embed via iframe for maximum compatibility.
<iframe src="https://colorfyi.com/iframe/entity//" width="420" height="400" frameborder="0" style="border:0;border-radius:10px;max-width:100%" loading="lazy"></iframe>
Paste this URL in WordPress, Medium, or any oEmbed-compatible platform.
https://colorfyi.com/entity//
Add a dynamic SVG badge to your README or docs.
[](https://colorfyi.com/entity//)
Use the native HTML custom element.
Design systems increasingly generate their color palettes programmatically rather than hand-picking every swatch. A single well-chosen brand color can seed an entire system: a full tonal scale, accessible text pairings, a dark mode variant, and harmonious companion hues. The underlying algorithms determine whether the result looks professionally crafted or computationally stiff.
This tutorial explains the core algorithms behind dynamic color generation â shade scales, contrast-aware selection, theme derivation, automatic dark mode, and the libraries that implement these techniques â with practical examples you can apply to your own design system.
Shade Generation Algorithms
A shade scale is a systematic sequence of tonal variants for a single hue: from near-white at the light end to near-black at the dark end. Tailwind CSS's 50â950 scale is the most familiar example. The quality of a shade scale depends entirely on the algorithm used to generate it.
The Problem with HSL-Based Shade Generation
The naive approach â starting from an HSL color and incrementing lightness in equal steps â produces uneven-looking scales. Because HSL's lightness axis is not perceptually uniform, a step from L=40 to L=50 looks different depending on the hue. Yellow shifts dramatically; blue barely moves. The resulting scale looks hand-crafted and inconsistent.
// Naive approach â do NOT do this
function hslShades(hue, saturation, steps = 10) {
return Array.from({ length: steps }, (_, i) => {
const lightness = 5 + (i / (steps - 1)) * 90;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
});
}
The output looks acceptable for mid-range yellows and greens but degrades badly for blues, purples, and desaturated hues.
OKLCH-Based Shade Generation
The correct approach uses OKLCH, where the L axis is perceptually calibrated. Equal L increments produce equal-looking steps regardless of hue. The algorithm is:
- Convert the base brand color to OKLCH
- Define the lightness range (e.g., L = 0.97 at shade 50, down to L = 0.15 at shade 950)
- Interpolate chroma along with lightness â very light and very dark shades have inherently lower chroma than mid-tone shades
- Hold hue constant (or apply a small hue rotation for aesthetic warmth/coolness shifts)
- Convert each OKLCH point back to HEX or sRGB
// OKLCH-based shade generation
function oklchShades(baseHex) {
const { L, C, H } = hexToOklch(baseHex);
// Lightness stops for a 11-step scale (50â950)
const lightnessStops = [0.97, 0.93, 0.86, 0.78, 0.68, 0.58, 0.48, 0.39, 0.30, 0.22, 0.15];
// Chroma curve: peaks at the base color's chroma, tapers toward extremes
const chromaStops = lightnessStops.map(l => {
const distance = Math.abs(l - L);
const falloff = Math.max(0, 1 - distance * 1.5);
return C * falloff;
});
return lightnessStops.map((l, i) => ({
oklch: `oklch(${l} ${chromaStops[i].toFixed(3)} ${H})`,
hex: oklchToHex(l, chromaStops[i], H),
}));
}
The chroma falloff curve is the most important tuning parameter. Allowing full chroma at the dark end produces shades that look unnaturally saturated and dark. Tapering chroma as you approach the light end avoids washed-out pastels with too little color character. The exact falloff shape is a design decision â some systems use a quadratic curve, others a piecewise linear function.
Use the Shade Generator to see this algorithm applied to any brand color. It produces a Tailwind-compatible scale with accessible defaults.
Hue Rotation for Temperature Shifts
Many design systems apply a small hue rotation alongside lightness â lighter shades shift slightly warmer (lower H values toward yellow), darker shades shift slightly cooler (higher H values toward blue/violet). This mimics how physical materials actually behave: a dark navy blue looks slightly warmer in its light tints.
// Add subtle hue rotation
function shadeWithHueShift(L_target, C_target, H_base, L_base) {
const lightnessDelta = L_target - L_base;
// Shift up to Âą10 degrees: warm (â) for light shades, cool (+) for dark
const hueShift = -lightnessDelta * 15;
return { L: L_target, C: C_target, H: H_base + hueShift };
}
The magnitude of the hue shift is a stylistic choice. Tailwind's own palette applies subtle hue shifts. Linear (no shift) scales look clean but can feel mechanical.
Contrast-Aware Color Selection
Generating a beautiful shade scale is only half the problem. For each shade to be usable as a text color or background, you need to know which shade of the scale to pair it with for accessible contrast.
Algorithm: Find the Minimum-Contrast Passing Shade
Given a background color and a shade scale, find the darkest shade on the scale that passes WCAG AA (4.5:1 for normal text):
function findAccessibleTextShade(backgroundHex, shadeScale) {
const bgLuminance = relativeLuminance(backgroundHex);
for (const shade of shadeScale) {
const shadeLuminance = relativeLuminance(shade.hex);
const ratio = contrastRatio(bgLuminance, shadeLuminance);
if (ratio >= 4.5) {
return shade; // First shade that passes
}
}
return shadeScale[shadeScale.length - 1]; // Darkest available
}
function contrastRatio(L1, L2) {
const lighter = Math.max(L1, L2);
const darker = Math.min(L1, L2);
return (lighter + 0.05) / (darker + 0.05);
}
For most practical brand colors, the shade 700 or 800 of the scale will pass AA against white, and the shade 50 or 100 will pass AA against the darkest shade in the scale.
Generating Accessible Text Tokens
A complete accessible token system pairs every background shade with at least one foreground shade that passes WCAG AA. The common pattern:
function generateAccessiblePairs(shadeScale) {
const pairs = {};
shadeScale.forEach((shade, index) => {
// Find passing text colors for this background
const passingShades = shadeScale.filter(textShade => {
const ratio = contrastRatio(
relativeLuminance(shade.hex),
relativeLuminance(textShade.hex)
);
return ratio >= 4.5;
});
pairs[shade.name] = {
background: shade.hex,
textPrimary: passingShades[passingShades.length - 1]?.hex, // Darkest passing
textSecondary: passingShades[passingShades.length - 3]?.hex, // Slightly lighter
};
});
return pairs;
}
For a blue scale where the brand color is #2563EB (blue-600): - blue-50 background â text primary: blue-900, text secondary: blue-700 - blue-600 background â text primary: white (#FFFFFF) or blue-50 - blue-900 background â text primary: white (#FFFFFF) or blue-100
The Shade Generator shows contrast ratios alongside each generated shade so you can immediately identify which pairs work.
Theme Generation from a Brand Color
Given a single brand color â the one that appears on a company's logo and marketing materials â you can derive a complete design system theme: primary, neutral, and semantic color roles.
Step 1: Anchor the Primary Scale
Extract the OKLCH values of the brand color and generate the full shade scale as described above. Set the brand color as shade 600 (or wherever it sits in the perceived brightness range â typically the 500â700 range for most branded colors).
Step 2: Derive a Neutral Scale
Most design systems need a neutral gray that feels harmonious with the brand color. A warm neutral is derived by taking the brand color's hue and desaturating it heavily:
function deriveNeutralScale(brandOklch) {
const { H } = brandOklch;
// Use the brand hue at very low chroma â just enough to feel warm/cool
const neutralBaseChroma = 0.010; // Nearly gray, slight hue influence
return generateOklchShades({
L: 0.55, C: neutralBaseChroma, H: H
});
}
For a blue brand (#2563EB, hue â 260°), this generates a slightly blue-gray neutral scale â cooler than pure gray, harmonizing with the brand color. For an orange brand, the neutrals lean warm.
Step 3: Assign Semantic Roles
Semantic tokens map the generated scales to functional roles:
:root {
/* Generated from brand blue #2563EB */
--color-primary-50: /* blue scale 50 */;
--color-primary-600: /* blue scale 600 = brand color */;
--color-primary-900: /* blue scale 900 */;
/* Generated from brand hue + desaturated (warm neutral) */
--color-neutral-50: /* neutral scale 50 */;
--color-neutral-900: /* neutral scale 900 */;
/* Semantic assignments */
--color-bg-base: var(--color-neutral-50);
--color-text-primary: var(--color-neutral-900);
--color-action-primary: var(--color-primary-600);
--color-action-primary-hover: var(--color-primary-700);
--color-action-primary-text: #FFFFFF;
}
Step 4: Generate Companion Accent Colors
For a complete palette, you may want 1â2 accent colors that harmonize with the brand. The Palette Generator can compute harmonic relationships â complementary (opposite on the hue wheel), triadic, or analogous companions.
Programmatically, rotating the OKLCH hue by fixed offsets gives you harmonious companions with matched lightness and chroma:
function generateCompanions(brandOklch, scheme = 'triadic') {
const { L, C, H } = brandOklch;
const rotations = {
complementary: [180],
triadic: [120, 240],
analogous: [30, -30],
split_complementary: [150, 210],
};
return rotations[scheme].map(rotation => ({
L, C,
H: (H + rotation + 360) % 360
}));
}
Because OKLCH's L and C are independent of hue, companion colors generated this way have the same lightness and chroma as the brand color â they are automatically perceptually balanced, requiring no manual adjustment.
Automatic Dark Mode Palette
Generating a dark mode palette from a light mode palette is not simply a matter of reversing the shade assignments. A dark mode palette requires:
- Darker backgrounds (obviously)
- Lighter text that maintains the same contrast ratios as the light mode text
- Adjusted primary colors â mid-tone brand shades (500â600) often look correct on light backgrounds but appear overly bright or saturated on dark ones
- Reduced chroma in some areas â dark surfaces with high chroma can look aggressive
Algorithm: Auto-Generate Dark Mode Tokens
The simplest reliable algorithm maps light-mode shade indices to dark-mode equivalents using a reflection function:
// Light mode: background uses light shades, text uses dark shades
// Dark mode: invert the background/text relationship
const LIGHT_TO_DARK_MAP = {
// Background shades: reflect
50: 900, 100: 800, 200: 700,
// Mid-range shades: adjust slightly
300: 700, 400: 600, 500: 500,
600: 400, 700: 300, 800: 200,
// Text shades: reflect
900: 100, 950: 50,
};
function generateDarkTokens(lightTokens, shadeScale) {
return Object.entries(lightTokens).reduce((dark, [token, shade]) => {
const darkShadeIndex = LIGHT_TO_DARK_MAP[shade.index];
dark[token] = shadeScale[darkShadeIndex];
return dark;
}, {});
}
Chroma Reduction for Dark Surfaces
Dark backgrounds with full-chroma accent colors can look jarring. A practical heuristic reduces chroma for colors used as background or surface tokens in dark mode, while preserving it for interactive (button, link) colors:
function darkSurfaceColor(oklch) {
return {
...oklch,
L: oklch.L * 0.3, // Much darker
C: oklch.C * 0.6, // Reduce chroma to avoid harshness
};
}
function darkInteractiveColor(oklch) {
return {
...oklch,
L: Math.min(0.75, oklch.L + 0.15), // Lighter for visibility
C: oklch.C, // Keep full chroma for vibrancy
};
}
For example, starting from a brand blue #2563EB (â oklch(0.55 0.21 264)):
- Light mode action button: oklch(0.55 0.21 264) â #2563EB
- Dark mode action button: oklch(0.70 0.21 264) â approximately #60A5FA (blue-400) â brighter to stand out on dark backgrounds without losing saturation
Verify dark mode contrast ratios using the same algorithm as light mode. White (#FFFFFF) text on dark-mode surface colors often needs to be replaced by near-white variants of the brand's neutral scale â --color-neutral-100 or --color-neutral-50 â rather than pure white, which can look harsh on OLED screens.
CSS Implementation: Dark Mode Variables
:root {
/* Light mode */
--color-bg-base: oklch(0.97 0.01 264);
--color-bg-surface: oklch(1.00 0.00 0);
--color-text-primary: oklch(0.18 0.03 264);
--color-action-primary: oklch(0.55 0.21 264);
}
@media (prefers-color-scheme: dark) {
:root {
/* Auto-generated dark mode */
--color-bg-base: oklch(0.16 0.02 264);
--color-bg-surface: oklch(0.22 0.02 264);
--color-text-primary: oklch(0.93 0.01 264);
--color-action-primary: oklch(0.70 0.21 264);
}
}
Libraries and Tools
Several mature libraries implement these algorithms at production quality:
Color Generation Libraries
Radix UI Colors â Open-source, algorithmically generated color scales with automatic dark mode and WCAG-tested pairings. Uses a CIELAB-based algorithm. The most battle-tested open-source system.
npm install @radix-ui/colors
import { blue, blueDark } from '@radix-ui/colors';
// blue.blue9 â the most vivid blue, light mode
// blueDark.blue9 â the equivalent in dark mode
Palette.js / Culori â A low-level JavaScript color library with full OKLCH support, useful for building your own algorithms:
import { oklch, formatHex, interpolate, samples } from 'culori';
const brandOklch = oklch('#2563EB');
const scale = samples(11).map(t => ({
t,
color: formatHex({
mode: 'oklch',
l: 0.15 + t * 0.82, // L from 0.15 (dark) to 0.97 (light)
c: brandOklch.c * Math.sin(t * Math.PI), // Chroma peaks at mid-tone
h: brandOklch.h,
})
}));
ColorBox â Lyft's open-source tool (originally by Javier Arce). Generates accessible color scales with a curve-based lightness and saturation editor. Provides a visual UI for what this tutorial describes algorithmically.
Design Tool Integrations
Figma tokens plugin â Works with design tokens in JSON format. You can define OKLCH-based tokens and sync them to CSS custom properties.
Style Dictionary â Amazon's open-source design token transformer. Useful for converting a token definition file to CSS custom properties, SCSS variables, iOS/Android constants, or any other platform format.
{
"color": {
"primary": {
"600": { "value": "oklch(0.55 0.21 264)", "comment": "Brand blue" }
}
}
}
Online Tools
The Shade Generator at ColorFYI applies OKLCH-aware shade generation to any brand color and outputs a Tailwind-compatible scale with contrast ratios. The Palette Generator computes complementary, triadic, and analogous color harmonies.
Key Takeaways
- HSL-based shade generation produces uneven scales because HSL's lightness is not perceptually uniform. Equal L-increments look very different depending on the hue.
- OKLCH-based shade generation produces visually even steps by exploiting the calibrated L axis. Include a chroma falloff curve â taper chroma toward the extremes of the scale for natural-looking tints and shades.
- Contrast-aware token generation pairs each background shade with the minimum-contrast-passing text shade using the WCAG relative luminance formula. Always verify ratios rather than assuming they are adequate.
- Theme generation from a brand color produces: (1) a primary scale anchored to the brand's shade, (2) a harmoniously warm/cool neutral scale using the same hue at low chroma, (3) semantic token assignments, and (4) optional accent companions generated by rotating the OKLCH hue.
- Dark mode palette generation inverts the shade role assignments and adjusts chroma: surface colors use reduced chroma to avoid harshness; interactive (button, link) colors use increased lightness to stay vivid on dark backgrounds.
- Libraries like Radix UI Colors and Culori implement these algorithms at production quality. Use the Shade Generator and Palette Generator for interactive, no-code access to the same techniques.