← Blog9 min read#color palette#react#tints

Color Palette Generator in React: Tints, Shades and WCAG Checker

Build a full color palette generator in React - tints, shades, hex/HSL conversion, and live WCAG contrast checking in under 200 lines.

Color swatches arranged in tint and shade gradients on a design desk

Why Build Your Own Color Palette Generator?

Every design system eventually needs a color scale. You pick a brand color, and then you need a 50, a 100, a 200... all the way to 900, plus dark mode variants, semantic tokens, and the occasional one-off that doesn't fit anywhere. Tools like Tailwind and Radix have done it for their own palettes, but the second your designer hands you a hex code from a brand brief, you're rolling your own.

Honestly, most palette generators online are fine for quick exploration but terrible for production. They dump a list of hex values with no contrast data, no HSL breakdown, and zero accessibility information. You're left copy-pasting into a checker separately - which you probably won't do consistently.

The approach here is different. We're building a self-contained React component that takes any hex input, generates a 9-stop tint/shade scale (50–900), and immediately runs WCAG 2.1 contrast checks against white and black at each stop. You'll see at a glance which stops pass AA, which pass AAA, and which fail entirely. Everything runs client-side with zero dependencies beyond React 19.

If you already have a working design system, you might not need to build this from scratch - you can browse components in the Empire UI library and pull from there. But understanding the math behind color scaling is worth the 30 minutes this will take.

The Math: HSL, Tints, and Shades

Color manipulation in code lives and dies on color space choice. RGB is how monitors render pixels, but it's a terrible space for generating perceptually even scales. Shifting red by 10 units doesn't look like the same change as shifting green by 10 units. HSL - hue, saturation, lightness - is far more intuitive. You can hold hue and saturation fixed, then walk the lightness value to get a natural-looking scale.

For tints (lighter versions), you walk lightness upward - toward 100%. For shades (darker), you walk it downward - toward 0%. The naive approach is linear spacing: 9 stops across the full range. That works, but the perceptual jumps aren't uniform. A 10% lightness step near the top of the scale (90% β†’ 100%) looks almost invisible, while the same step near the middle is dramatic.

In practice, a slight curve on the lightness distribution produces better results. You can use a simple power curve or just manually bias your step sizes - more steps between 40% and 70% lightness where contrast changes fastest. The implementation below uses a precalculated array of lightness percentages that maps to stops 50 through 900, tuned to match how Tailwind v3 and Radix UI generate their scales.

Worth noting: the stop names (50, 100, 200...) are just labels. The math doesn't care. You're really just picking 9 lightness points on a 0–100 scale and converting back to hex. The naming convention exists so developers have a shared vocabulary - 'use color-500 as the primary' is unambiguous.

Hex ↔ HSL Conversion Utilities

Before generating any scale, you need reliable conversion functions. These are pure utility functions - no hooks, no side effects, just math. Keep them in a separate colorUtils.ts file.

// colorUtils.ts
export function hexToHsl(hex: string): [number, number, number] {
  const r = parseInt(hex.slice(1, 3), 16) / 255;
  const g = parseInt(hex.slice(3, 5), 16) / 255;
  const b = parseInt(hex.slice(5, 7), 16) / 255;

  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  let h = 0, s = 0;
  const l = (max + min) / 2;

  if (max !== min) {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    switch (max) {
      case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
      case g: h = ((b - r) / d + 2) / 6; break;
      case b: h = ((r - g) / d + 4) / 6; break;
    }
  }

  return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
}

export function hslToHex(h: number, s: number, l: number): string {
  s /= 100;
  l /= 100;
  const a = s * Math.min(l, 1 - l);
  const f = (n: number) => {
    const k = (n + h / 30) % 12;
    const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
    return Math.round(255 * color).toString(16).padStart(2, '0');
  };
  return `#${f(0)}${f(8)}${f(4)}`;
}

These are standard algorithms - nothing exotic. The hexToHsl function destructures the three 8-bit channels, converts to 0–1 range, then does the classic min/max decomposition to compute hue, saturation, and lightness. hslToHex reverses it using the same per-channel offset trick. Both handle edge cases like achromatic colors (where s=0 and hue is undefined).

One subtle thing to watch: JavaScript's parseInt(hex.slice(1, 3), 16) will silently return NaN if your hex string is malformed. You'll want input validation before calling these - more on that in the component section.

Generating the Scale

With conversion utilities in place, the scale generator is straightforward. You take an input hex, extract the HSL values, lock the hue and saturation, then substitute in a fixed array of lightness values.

// colorUtils.ts (continued)
const LIGHTNESS_SCALE = [
  95, // 50
  88, // 100
  79, // 200
  68, // 300
  56, // 400
  45, // 500 - usually closest to the input color
  34, // 600
  24, // 700
  15, // 800
  8,  // 900
];

export const STOP_NAMES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];

export function generatePalette(hex: string) {
  const [h, s] = hexToHsl(hex);
  return STOP_NAMES.map((stop, i) => ({
    stop,
    hex: hslToHex(h, s, LIGHTNESS_SCALE[i]),
    lightness: LIGHTNESS_SCALE[i],
  }));
}

In practice, the lightness values in LIGHTNESS_SCALE are where most of the craft is. The numbers above are calibrated to produce visually balanced steps - you get roughly equal perceived contrast jumps between adjacent stops. Feel free to tweak them; just know that compressing the range (say, from 15–95 to 20–90) will soften the extremes and is sometimes what you want for dark mode tokens.

Quick aside: if you're building this for a design system that targets specific accessibility standards, you should probably pin stop-500 to the input color exactly rather than substituting your own lightness value for it. Users will expect the swatch they chose to appear somewhere in the scale. You can do that by finding which LIGHTNESS_SCALE entry is closest to the input's actual lightness and swapping that stop's value with the original hex.

WCAG Contrast Checking

This is the part most palette tools skip, and it's the part that matters most if you care about accessibility. WCAG 2.1 defines contrast ratios between foreground and background colors. AA level requires 4.5:1 for normal text and 3:1 for large text (18pt or 14pt bold). AAA requires 7:1 and 4.5:1 respectively.

The contrast ratio formula takes relative luminance. Luminance is not just lightness - it's a gamma-corrected, perceptually weighted sum of the RGB channels. The calculation involves a linearization step that catches a lot of developers off guard.

// colorUtils.ts (continued)
function relativeLuminance(hex: string): number {
  const r = parseInt(hex.slice(1, 3), 16) / 255;
  const g = parseInt(hex.slice(3, 5), 16) / 255;
  const b = parseInt(hex.slice(5, 7), 16) / 255;

  const linearize = (c: number) =>
    c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);

  const R = linearize(r);
  const G = linearize(g);
  const B = linearize(b);

  return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}

export function contrastRatio(hex1: string, hex2: string): number {
  const l1 = relativeLuminance(hex1);
  const l2 = relativeLuminance(hex2);
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return parseFloat(((lighter + 0.05) / (darker + 0.05)).toFixed(2));
}

export type WcagLevel = 'AAA' | 'AA' | 'AA-large' | 'fail';

export function wcagLevel(ratio: number): WcagLevel {
  if (ratio >= 7) return 'AAA';
  if (ratio >= 4.5) return 'AA';
  if (ratio >= 3) return 'AA-large';
  return 'fail';
}

The linearization threshold of 0.03928 is from the WCAG 2.1 spec - don't change it. And the 0.2126 / 0.7152 / 0.0722 weights reflect how human vision weighs green much more heavily than blue. Green contributes 71.5% of perceived luminance. That's why saturated blue looks darker than saturated green even at the same HSL lightness value.

One more thing - these functions give you contrast against any arbitrary background, not just white and black. For a palette tool, checking both is the right default. In your component, you'll run each palette swatch against #ffffff and #000000 and display both ratios. That tells you whether the swatch works as text on a light background, dark background, or neither.

The React Component

Now the fun part. The component itself is a controlled input for the hex value, a derived palette (computed with useMemo), and a grid of swatches. Each swatch shows the hex, the contrast ratios against white and black, and a WCAG badge.

// ColorPaletteGenerator.tsx
import { useState, useMemo } from 'react';
import {
  generatePalette,
  contrastRatio,
  wcagLevel,
  STOP_NAMES,
} from './colorUtils';

const isValidHex = (v: string) => /^#[0-9a-fA-F]{6}$/.test(v);

const BADGE_STYLES: Record<string, string> = {
  AAA: 'bg-green-600 text-white',
  AA: 'bg-blue-600 text-white',
  'AA-large': 'bg-yellow-500 text-black',
  fail: 'bg-red-600 text-white',
};

export function ColorPaletteGenerator() {
  const [input, setInput] = useState('#6366f1');

  const palette = useMemo(() => {
    if (!isValidHex(input)) return null;
    return generatePalette(input).map((swatch) => ({
      ...swatch,
      onWhite: contrastRatio(swatch.hex, '#ffffff'),
      onBlack: contrastRatio(swatch.hex, '#000000'),
    }));
  }, [input]);

  return (
    <div className="max-w-3xl mx-auto p-6 font-mono">
      <label className="block text-sm mb-2 text-gray-600">Base color</label>
      <div className="flex gap-3 mb-8 items-center">
        <input
          type="color"
          value={isValidHex(input) ? input : '#6366f1'}
          onChange={(e) => setInput(e.target.value)}
          className="w-12 h-10 cursor-pointer rounded border border-gray-300"
        />
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="#6366f1"
          maxLength={7}
          className="border border-gray-300 rounded px-3 py-2 w-36 text-sm"
        />
      </div>

      {palette && (
        <div className="grid gap-2">
          {palette.map((swatch) => (
            <div
              key={swatch.stop}
              className="flex items-center gap-4 rounded-lg p-3"
              style={{ backgroundColor: swatch.hex }}
            >
              <span
                className="w-10 text-xs font-bold"
                style={{ color: swatch.lightness > 50 ? '#111' : '#fff' }}
              >
                {swatch.stop}
              </span>
              <span
                className="w-24 text-xs"
                style={{ color: swatch.lightness > 50 ? '#111' : '#fff' }}
              >
                {swatch.hex}
              </span>
              <span className={`text-xs px-2 py-0.5 rounded ${BADGE_STYLES[wcagLevel(swatch.onWhite)]}`}>
                on white {swatch.onWhite}:1
              </span>
              <span className={`text-xs px-2 py-0.5 rounded ${BADGE_STYLES[wcagLevel(swatch.onBlack)]}`}>
                on black {swatch.onBlack}:1
              </span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

The isValidHex guard before calling generatePalette is important - without it, a user typing a hex code character by character will trigger calculations with malformed strings and you'll get NaN everywhere. The useMemo dependency on input means the palette only recomputes when the user finishes (or the native color picker fires on every tick, which is fine since the computation is cheap).

Notice the inline style for text color on each swatch: lightness > 50 ? '#111' : '#fff'. That's a rough but effective heuristic - below 50% lightness, white text is more likely to pass contrast; above 50%, dark text works better. For production you'd calculate this properly using contrastRatio, but this avoids a layout flash.

Look, the Tailwind classes here assume you already have Tailwind set up. If you don't, swap them for inline styles or CSS modules - the logic is identical. You can also style the whole thing to match any of the glassmorphism components or other design styles in the Empire UI library. The badge styling in particular lends itself to a frosted-glass treatment.

That said, don't get distracted by aesthetics until the math is solid. Run your contrast calculations first. Verify that your stop-100 swatch against white gives a ratio around 1.1:1 (nearly invisible, which is expected for very light tints). Verify stop-700 against white gives something north of 4.5:1. Those sanity checks catch bugs in your conversion utilities early.

Exporting Tokens and Real-World Usage

A generator is only useful if you can get the output into your codebase. The most common formats are CSS custom properties, a Tailwind config object, and a JSON token file for Style Dictionary. Adding an export button is straightforward - serialize the palette array into each format and trigger a download.

export function paletteToCssVars(colorName: string, palette: ReturnType<typeof generatePalette>) {
  const vars = palette
    .map(({ stop, hex }) => `  --color-${colorName}-${stop}: ${hex};`)
    .join('\n');
  return `:root {\n${vars}\n}`;
}

export function paletteToTailwindConfig(colorName: string, palette: ReturnType<typeof generatePalette>) {
  const entries = palette
    .map(({ stop, hex }) => `      '${stop}': '${hex}',`)
    .join('\n');
  return `// tailwind.config.js\nmodule.exports = {\n  theme: {\n    extend: {\n      colors: {\n        ${colorName}: {\n${entries}\n        },\n      },\n    },\n  },\n};`;
}

For CSS custom properties, you can reference this output in combination with the gradient generator - build a palette from your brand hex, then feed the 300 and 700 stops into a gradient. That pattern works really well for hero sections and feature cards.

Worth noting: if you're working in a design system context and care about semantic tokens (not just raw color stops), consider a second pass after generating the scale. Map primary, primary-hover, primary-disabled, primary-text etc. to specific stops based on the WCAG results. Stop-500 might be your primary button background, stop-600 the hover state, and stop-50 the disabled background. The WCAG data you already computed tells you whether those combinations actually pass - you don't need a separate tool.

This workflow pairs naturally with a design tokens guide approach where you separate the raw palette from the semantic aliases. Raw palette goes in one file, semantic tokens reference those values. That separation makes dark mode trivial - you remap the semantic tokens without touching the raw scale.

One more thing - if you're using this in a Storybook setup, consider wrapping the generator in a Storybook addon panel rather than a story. That way your team can experiment with new brand colors directly inside Storybook and see how they affect existing components in real time.

FAQ

What's the difference between tints and shades?

Tints are lighter variants of a color - you add white (increase lightness in HSL). Shades are darker - you add black (decrease lightness). In a standard 50–900 scale, 50 through 400 are tints and 600 through 900 are shades, with 500 roughly matching the base color.

Does this approach work for all colors, including muted or desaturated ones?

Yes, but very low saturation colors (grays, near-whites) can look almost identical across stops because there's not much color to vary. If saturation is below 10%, consider generating a true gray scale and layering a color tint on top instead.

Which WCAG level should I target for UI components?

AA (4.5:1 for text, 3:1 for large text and UI components) is the legal minimum in most jurisdictions and the standard target for most products. AAA (7:1) is ideal for body text if you can achieve it without compromising your brand color.

Can I use this with the Empire UI component library?

Absolutely. Generate your palette, export it as CSS custom properties, and plug those variables into any Empire UI component's color props. The glassmorphism generator and box shadow generator both accept custom color values.

Free components in 41 styles
React & Tailwind, copy-paste ready.
Browse β†’

Read next

Building Design Systems That Scale: Engineering Guide 2026 β†’WCAG 2025 Accessibility Guide for React Developers β†’React Accessibility (a11y): The 8 Patterns You Keep Getting Wrong β†’Color Contrast Checker: WCAG AA/AAA and APCA in 2026 β†’