weableColor Icon
OverviewPricingDocsToolsBlogCase Studies
Get Started
Back to Blog

Advanced Tutorial: Generating Perceptually Uniform Palettes with LCH

7/27/2025

color
accessibility
palette
generator
lch
perceptual uniformity
design
culori
@weable/a11y-color-utils

Advanced Tutorial: Generating Perceptually Uniform Palettes with LCH

In our first tutorial, we built a palette generator by adjusting the lightness of a base color in RGB space. While simple, this approach doesn't always produce perceptually uniform steps – meaning the visual difference between consecutive colors might not feel consistent.

Enter the LCH color space (Lightness, Chroma, Hue). LCH is designed to align more closely with human perception. Adjusting the 'L' (Lightness) channel in LCH tends to create smoother, more natural-looking gradients and palettes.

This tutorial demonstrates how to build an accessible palette generator using LCH color manipulations, leveraging the culori library for color conversions and @weable/a11y-color-utils for the crucial accessibility checks.

Prerequisites

  • Node.js and npm (or yarn) installed.
  • Basic understanding of JavaScript/TypeScript.
  • Familiarity with color spaces (HEX, RGB, LCH basics).
  • Completion of the first palette generator tutorial (recommended).

Why LCH?

  • Perceptual Uniformity: Changes in Lightness (L) correspond more directly to perceived brightness changes.
  • Consistent Saturation (Chroma): Modifying Lightness often has less impact on perceived saturation compared to HSL/RGB adjustments.
  • Predictable Hue: Hue (H) remains consistent while adjusting Lightness.

This generally leads to palettes where tints and shades feel more related to the base color and have smoother transitions.

Project Setup

  1. Create a new project directory (or reuse the previous one):
    mkdir lch-palette-generator
    cd lch-palette-generator
    npm init -y
    
  2. Install dependencies:
    # For LCH color conversions and manipulations
    npm install culori 
    # For accessibility checks
    npm install @weable/a11y-color-utils
    
  3. Create an index.js file.

Core Logic (index.js)

We'll adapt the previous generator. Instead of adjustColorLightness, we'll convert the base color to LCH, adjust the l property, and convert back to RGB/HEX.

// index.js
import { formatHex, modeLch, parseHex } from 'culori/fn'; // Import specific culori functions
import {
  getColorContrast,
  getColorContrastAPCA,
  hexToRgb,
} from '@weable/a11y-color-utils';

// Ensure LCH mode is registered if not using the main culori import
modeLch(); 

// Function to generate tints and shades using LCH lightness
function generateLchPalette(baseColorHex, steps = 5, amount = 10) { // Amount is now direct LCH lightness delta
  const baseColorLch = parseHex(baseColorHex)?.lch;
  if (!baseColorLch) {
    console.error(`Invalid base color HEX: ${baseColorHex}`);
    return [];
  }

  const palette = [];

  // Generate shades (decreasing LCH lightness)
  for (let i = steps; i >= 0; i--) { // Go from darkest to base
    const l = Math.max(0, baseColorLch.l - amount * i);
    const shade = { mode: 'lch', l: l, c: baseColorLch.c, h: baseColorLch.h };
    const hex = formatHex(shade);
    if (hex) {
        palette.push({ color: hex, lch: shade });
    } else {
        console.warn(`Could not format shade ${i} to HEX.`);
    }
  }

  // Generate tints (increasing LCH lightness)
  for (let i = 1; i <= steps; i++) { // Go from base to lightest
    const l = Math.min(100, baseColorLch.l + amount * i);
    const tint = { mode: 'lch', l: l, c: baseColorLch.c, h: baseColorLch.h };
    const hex = formatHex(tint);
     if (hex) {
        palette.push({ color: hex, lch: tint });
     } else {
        console.warn(`Could not format tint ${i} to HEX.`);
     }
  }

  // Sort by LCH lightness (already mostly sorted but good practice)
  palette.sort((a, b) => a.lch.l - b.lch.l);

  return palette;
}

// Accessibility check function (reused from previous tutorial, adapted slightly)
function checkAccessibility(palette) {
  const results = [];
  const blackRgb = { r: 0, g: 0, b: 0 };
  const whiteRgb = { r: 255, g: 255, b: 255 };

  for (const item of palette) {
    const { color } = item;
    const rgb = hexToRgb(color); // Convert HEX from palette to RGB for checks

    if (!rgb) {
        console.warn(`Skipping accessibility check for invalid HEX: ${color}`);
        continue;
    }

    const contrastWhiteWcag = getColorContrast(rgb, whiteRgb);
    const contrastBlackWcag = getColorContrast(rgb, blackRgb);
    const contrastWhiteApca = getColorContrastAPCA(rgb, whiteRgb);
    const contrastBlackApca = getColorContrastAPCA(rgb, blackRgb);

    results.push({
      background: color,
      lch_l: item.lch.l.toFixed(1), // Show LCH lightness
      wcag: {
        vsWhite: contrastWhiteWcag.toFixed(2),
        passWhiteAA: contrastWhiteWcag >= 4.5,
        vsBlack: contrastBlackWcag.toFixed(2),
        passBlackAA: contrastBlackWcag >= 4.5,
      },
      apca: {
        vsWhite: contrastWhiteApca.toFixed(1),
        passWhiteLc60: Math.abs(contrastWhiteApca) >= 60,
        vsBlack: contrastBlackApca.toFixed(1),
        passBlackLc60: Math.abs(contrastBlackApca) >= 60,
      },
    });
  }
  return results;
}

// --- Example Usage ---
const baseColor = '#3498db'; // Example: A nice blue
const lchPalette = generateLchPalette(baseColor, 5, 8); // Use LCH steps (e.g., delta of 8 lightness)
const accessibilityReport = checkAccessibility(lchPalette);

console.log(`Accessibility Report for LCH Palette based on ${baseColor}:`);
console.table(accessibilityReport.map(r => ({
    Background: r.background,
    LCH_L: r.lch_l,
    'WCAG vs White': r.wcag.vsWhite + (r.wcag.passWhiteAA ? ' (AA✓)' : ' (AA✗)'),
    'WCAG vs Black': r.wcag.vsBlack + (r.wcag.passBlackAA ? ' (AA✓)' : ' (AA✗)'),
    'APCA vs White': r.apca.vsWhite + (r.apca.passWhiteLc60 ? ' (Lc60✓)' : ' (Lc60✗)'),
    'APCA vs Black': r.apca.vsBlack + (r.apca.passBlackLc60 ? ' (Lc60✓)' : ' (Lc60✗)'),
})));

Explanation

  1. Imports: We import formatHex, modeLch, and parseHex from culori/fn for efficient color conversions. We also import the necessary functions from @weable/a11y-color-utils.
  2. generateLchPalette:
    • Takes a base HEX, number of steps, and an amount representing the desired change in LCH Lightness per step (e.g., 8 units).
    • Uses parseHex and accesses the .lch property to get the LCH representation of the base color.
    • Generates shades by decreasing the l value (clamped at 0).
    • Generates tints by increasing the l value (clamped at 100).
    • Crucially, it keeps the c (Chroma) and h (Hue) values from the original base color constant for all variations.
    • Uses formatHex to convert the new LCH colors back to HEX strings.
    • Sorts the final palette by LCH lightness.
  3. checkAccessibility: This function is largely the same as before, but we added hexToRgb inside the loop because our palette now stores HEX colors generated from LCH. We also added the LCH Lightness (lch_l) to the output table for reference.
  4. Example Usage: We call generateLchPalette with a step amount suitable for LCH (e.g., 8 lightness units) and then generate/display the accessibility report as before.

Running the Code

node index.js

You'll see a table similar to the first tutorial, but the colors generated (and their perceived steps) will likely look smoother and more natural due to the LCH manipulation.

Comparison to RGB/HSL Lightness Adjustment

  • Smoothness: LCH adjustments generally yield visually smoother transitions in brightness.
  • Hue/Saturation Shifts: Adjusting lightness in RGB or even HSL can sometimes cause unexpected shifts in perceived hue or saturation. LCH is designed to minimize this when only the L channel is changed.
  • Predictability: The effect of changing L in LCH is often more predictable across different base colors.

However, LCH requires a dedicated library like culori for conversions.

Conclusion

While adjusting lightness in RGB/HSL is simpler, leveraging the LCH color space offers a more sophisticated approach to generating perceptually uniform color palettes. By combining LCH manipulation (using libraries like culori) with robust accessibility checks (using @weable/a11y-color-utils), you can create color systems that are both aesthetically pleasing and highly accessible.

This advanced technique provides finer control over the visual properties of your generated palettes, leading to more professional and user-friendly results.

weableColor Icon
weableColor

Making accessibility sexy, one color at a time. Professional tools for developers who care about inclusive design.

WCAG 2.1 AA
APCA Ready
Products

© 2025 weableColor. All rights reserved. Made with ❤️ for accessible design.

Built with:

TypeScript
SACA