Advanced Design SystemColor PaletteDocumentation

Building a Design System with OKLCH

Create a robust, scalable design system using OKLCH colors. Explore palette generation, semantic naming conventions, and documentation strategies.

Building a Design System with OKLCH

Creating a robust, scalable design system becomes significantly easier with OKLCH's perceptual uniformity. This comprehensive guide walks you through designing, implementing, and maintaining a production-ready design system that scales with your organization.

Why OKLCH for Design Systems?

Perceptual Uniformity: Every color step looks equally spaced to human eyes Predictable Manipulation: Lightening/darkening produces expected results Easy Dark Mode: Flip lightness values for automatic dark themes Better Accessibility: Consistent lightness makes WCAG compliance straightforward Future-Proof: Access to P3 and wider color gamuts

Phase 1: Foundation - Color Palette Generation

Step 1: Define Your Brand Hues

Start by selecting the core hues that represent your brand:

:root {
  /* Brand identity hues */
  --hue-primary: 250;      /* Blue - trust, stability */
  --hue-secondary: 340;    /* Pink - energy, creativity */
  --hue-accent: 140;       /* Green - growth, success */
  --hue-neutral: 270;      /* Cool gray - modern, clean */
  
  /* Extended palette */
  --hue-warning: 85;       /* Yellow */
  --hue-error: 25;         /* Red */
  --hue-info: 210;         /* Light blue */
}

Step 2: Generate Shade Scales

Use consistent lightness steps for predictable, harmonious scales:

:root {
  /* Primary blue - 0.10 lightness steps */
  --primary-50: oklch(0.97 0.05 var(--hue-primary));
  --primary-100: oklch(0.93 0.08 var(--hue-primary));
  --primary-200: oklch(0.85 0.12 var(--hue-primary));
  --primary-300: oklch(0.75 0.15 var(--hue-primary));
  --primary-400: oklch(0.65 0.18 var(--hue-primary));
  --primary-500: oklch(0.55 0.20 var(--hue-primary));  /* Base shade */
  --primary-600: oklch(0.45 0.20 var(--hue-primary));
  --primary-700: oklch(0.35 0.18 var(--hue-primary));
  --primary-800: oklch(0.25 0.15 var(--hue-primary));
  --primary-900: oklch(0.15 0.10 var(--hue-primary));
}

Key principles:

  • Lightness: 0.10 increments for consistent perceived steps
  • Chroma: Peak at mid-tones (500-600), reduce at extremes
  • Hue: Keep constant for color family cohesion

Step 3: Create Neutral Scales

Neutrals are critical - use zero chroma to avoid color casts:

:root {
  /* True neutrals - zero chroma */
  --neutral-0: oklch(1.00 0.00 0);       /* Pure white */
  --neutral-50: oklch(0.97 0.00 0);
  --neutral-100: oklch(0.93 0.00 0);
  
  /* Slightly tinted neutrals - subtle warmth/coolness */
  --neutral-200: oklch(0.85 0.01 var(--hue-neutral));
  --neutral-300: oklch(0.75 0.02 var(--hue-neutral));
  --neutral-400: oklch(0.65 0.03 var(--hue-neutral));
  --neutral-500: oklch(0.55 0.04 var(--hue-neutral));
  --neutral-600: oklch(0.45 0.04 var(--hue-neutral));
  --neutral-700: oklch(0.35 0.04 var(--hue-neutral));
  --neutral-800: oklch(0.25 0.03 var(--hue-neutral));
  --neutral-900: oklch(0.15 0.02 var(--hue-neutral));
  --neutral-950: oklch(0.10 0.02 var(--hue-neutral));
  --neutral-1000: oklch(0.00 0.00 0);    /* Pure black */
}

Pro tip: Introduce subtle chroma (0.01-0.04) in mid-tones for a warmer/cooler feel while keeping extremes pure.

Phase 2: Semantic Color Tokens

Map shade scales to semantic meanings for flexibility and maintainability:

:root {
  /* ============================================
     SURFACE COLORS - Backgrounds and containers
     ============================================ */
  --surface-primary: var(--neutral-0);         /* Main background */
  --surface-secondary: var(--neutral-50);      /* Elevated surfaces */
  --surface-tertiary: var(--neutral-100);      /* Nested surfaces */
  --surface-accent: var(--primary-50);         /* Subtle brand accent */
  --surface-inverse: var(--neutral-900);       /* Dark surfaces in light mode */
  
  /* ============================================
     TEXT COLORS - Typography hierarchy
     ============================================ */
  --text-primary: var(--neutral-900);          /* Main content */
  --text-secondary: var(--neutral-700);        /* Supporting text */
  --text-tertiary: var(--neutral-600);         /* Disabled, placeholder */
  --text-inverse: var(--neutral-50);           /* Text on dark backgrounds */
  --text-link: var(--primary-600);             /* Hyperlinks */
  --text-link-hover: var(--primary-700);       /* Link hover state */
  
  /* ============================================
     BORDER COLORS - Dividers and outlines
     ============================================ */
  --border-default: var(--neutral-300);        /* Standard borders */
  --border-subtle: var(--neutral-200);         /* Subtle dividers */
  --border-strong: var(--neutral-400);         /* Emphasized borders */
  --border-inverse: var(--neutral-700);        /* Borders on dark backgrounds */
  
  /* ============================================
     INTERACTIVE COLORS - Buttons, links, inputs
     ============================================ */
  --interactive-default: var(--primary-600);
  --interactive-hover: var(--primary-700);
  --interactive-active: var(--primary-800);
  --interactive-disabled: var(--neutral-300);
  --interactive-focus: var(--primary-500);     /* Focus ring */
  
  /* ============================================
     FEEDBACK COLORS - Status and alerts
     ============================================ */
  --feedback-success: oklch(0.65 0.20 145);
  --feedback-success-bg: oklch(0.95 0.05 145);
  --feedback-success-border: oklch(0.75 0.15 145);
  
  --feedback-warning: oklch(0.70 0.18 85);
  --feedback-warning-bg: oklch(0.95 0.05 85);
  --feedback-warning-border: oklch(0.80 0.15 85);
  
  --feedback-error: oklch(0.55 0.22 25);
  --feedback-error-bg: oklch(0.95 0.05 25);
  --feedback-error-border: oklch(0.70 0.18 25);
  
  --feedback-info: oklch(0.62 0.19 250);
  --feedback-info-bg: oklch(0.95 0.05 250);
  --feedback-info-border: oklch(0.75 0.15 250);
}

Phase 3: Dark Mode Implementation

OKLCH makes dark mode straightforward - invert lightness values:

/* Light mode (default) */
:root {
  --mode-bg: oklch(0.98 0.00 0);
  --mode-surface: oklch(0.95 0.01 270);
  --mode-text: oklch(0.20 0.02 270);
  --mode-text-muted: oklch(0.50 0.03 270);
  --mode-border: oklch(0.85 0.02 270);
}

/* Dark mode - flip lightness */
@media (prefers-color-scheme: dark) {
  :root {
    --mode-bg: oklch(0.12 0.02 270);
    --mode-surface: oklch(0.18 0.03 270);
    --mode-text: oklch(0.90 0.02 270);
    --mode-text-muted: oklch(0.65 0.04 270);
    --mode-border: oklch(0.25 0.03 270);
  }
}

/* Class-based dark mode (for manual toggle) */
.dark {
  --mode-bg: oklch(0.12 0.02 270);
  --mode-surface: oklch(0.18 0.03 270);
  --mode-text: oklch(0.90 0.02 270);
  --mode-text-muted: oklch(0.65 0.04 270);
  --mode-border: oklch(0.25 0.03 270);
  
  /* Interactive elements need special handling in dark mode */
  --interactive-default: var(--primary-500);
  --interactive-hover: var(--primary-400);
  --interactive-active: var(--primary-300);
}

Advanced Theming Pattern

/* Theme architecture */
:root {
  /* Base palette (never changes) */
  --color-primary-500: oklch(0.55 0.20 250);
  --color-neutral-50: oklch(0.97 0.00 0);
  --color-neutral-900: oklch(0.15 0.02 270);
  
  /* Semantic tokens (theme-aware) */
  --bg: var(--theme-bg, var(--color-neutral-50));
  --text: var(--theme-text, var(--color-neutral-900));
}

/* Light theme */
[data-theme="light"] {
  --theme-bg: var(--color-neutral-50);
  --theme-text: var(--color-neutral-900);
}

/* Dark theme */
[data-theme="dark"] {
  --theme-bg: var(--color-neutral-900);
  --theme-text: var(--color-neutral-50);
}

/* High contrast theme */
[data-theme="high-contrast"] {
  --theme-bg: oklch(0.00 0.00 0);      /* Pure black */
  --theme-text: oklch(1.00 0.00 0);     /* Pure white */
  --border: oklch(1.00 0.00 0);         /* White borders */
}

Phase 4: Component Tokens

Create token layers for specific components:

:root {
  /* ============================================
     BUTTON TOKENS
     ============================================ */
  --button-primary-bg: var(--interactive-default);
  --button-primary-bg-hover: var(--interactive-hover);
  --button-primary-bg-active: var(--interactive-active);
  --button-primary-text: var(--text-inverse);
  --button-primary-border: var(--interactive-default);
  
  --button-secondary-bg: var(--surface-secondary);
  --button-secondary-bg-hover: var(--surface-tertiary);
  --button-secondary-text: var(--text-primary);
  --button-secondary-border: var(--border-default);
  
  --button-ghost-bg: transparent;
  --button-ghost-bg-hover: var(--surface-secondary);
  --button-ghost-text: var(--text-primary);
  
  /* ============================================
     CARD TOKENS
     ============================================ */
  --card-bg: var(--surface-primary);
  --card-border: var(--border-subtle);
  --card-shadow: oklch(0.50 0.00 0 / 0.08);
  --card-shadow-hover: oklch(0.50 0.00 0 / 0.12);
  --card-radius: 1rem;
  
  /* ============================================
     INPUT TOKENS
     ============================================ */
  --input-bg: var(--surface-primary);
  --input-bg-disabled: var(--surface-secondary);
  --input-border: var(--border-default);
  --input-border-hover: var(--border-strong);
  --input-border-focus: var(--interactive-focus);
  --input-text: var(--text-primary);
  --input-placeholder: var(--text-tertiary);
  --input-radius: 0.5rem;
  
  /* ============================================
     BADGE/TAG TOKENS
     ============================================ */
  --badge-success-bg: var(--feedback-success-bg);
  --badge-success-text: oklch(0.35 0.18 145);
  --badge-success-border: var(--feedback-success-border);
  
  --badge-warning-bg: var(--feedback-warning-bg);
  --badge-warning-text: oklch(0.40 0.15 85);
  --badge-warning-border: var(--feedback-warning-border);
  
  --badge-error-bg: var(--feedback-error-bg);
  --badge-error-text: oklch(0.35 0.20 25);
  --badge-error-border: var(--feedback-error-border);
}

Phase 5: Implementation Examples

Button Component (CSS)

.button {
  padding: 0.75rem 1.5rem;
  border-radius: 0.5rem;
  font-weight: 500;
  transition: all 200ms;
  border: 1px solid;
}

.button-primary {
  background: var(--button-primary-bg);
  color: var(--button-primary-text);
  border-color: var(--button-primary-border);
}

.button-primary:hover {
  background: var(--button-primary-bg-hover);
}

.button-primary:active {
  background: var(--button-primary-bg-active);
}

.button-primary:disabled {
  background: var(--interactive-disabled);
  cursor: not-allowed;
  opacity: 0.6;
}

.button-secondary {
  background: var(--button-secondary-bg);
  color: var(--button-secondary-text);
  border-color: var(--button-secondary-border);
}

.button-secondary:hover {
  background: var(--button-secondary-bg-hover);
}

Card Component (HTML)

<div class="card">
  <div class="card-header">
    <h3 class="card-title">Card Title</h3>
    <span class="badge badge-success">Active</span>
  </div>
  <div class="card-content">
    <p class="text-secondary">
      Card content uses semantic tokens for automatic theming
    </p>
  </div>
  <div class="card-footer">
    <button class="button button-primary">Action</button>
    <button class="button button-secondary">Cancel</button>
  </div>
</div>
.card {
  background: var(--card-bg);
  border: 1px solid var(--card-border);
  border-radius: var(--card-radius);
  box-shadow: 0 2px 8px var(--card-shadow);
  transition: box-shadow 200ms;
}

.card:hover {
  box-shadow: 0 4px 16px var(--card-shadow-hover);
}

.card-header {
  padding: 1.5rem;
  border-bottom: 1px solid var(--border-subtle);
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.card-title {
  color: var(--text-primary);
  font-size: 1.25rem;
  font-weight: 600;
}

.card-content {
  padding: 1.5rem;
}

.card-footer {
  padding: 1rem 1.5rem;
  border-top: 1px solid var(--border-subtle);
  display: flex;
  gap: 0.75rem;
  justify-content: flex-end;
}

Phase 6: Documentation

Color System Documentation Template

Create comprehensive documentation for your team:

# Design System - Color Guidelines

## Brand Colors

### Primary Blue
Our primary brand color represents trust and reliability.

| Token | Value | Usage |
|-------|-------|-------|
| `--primary-500` | `oklch(0.55 0.20 250)` | Primary actions, links, brand elements |
| `--primary-600` | `oklch(0.45 0.20 250)` | Hover states, emphasized elements |
| `--primary-700` | `oklch(0.35 0.18 250)` | Active states, pressed buttons |

**Accessibility**: Minimum lightness difference of 0.40 required for text contrast.

### Neutrals
Subtle cool-tinted grays for modern aesthetic.

| Token | Lightness | Chroma | Usage |
|-------|-----------|--------|-------|
| `--neutral-50` | 0.97 | 0.00 | Light backgrounds |
| `--neutral-500` | 0.55 | 0.04 | Mid-tone borders |
| `--neutral-900` | 0.15 | 0.02 | Dark text |

## Semantic Tokens

Use semantic tokens instead of raw color values:

```css
/* ❌ Don't */
.button {
  background: oklch(0.55 0.20 250);
}

/* ✅ Do */
.button {
  background: var(--interactive-default);
}
```

## Dark Mode

Our dark mode inverts lightness values while maintaining semantic meaning:

**Light mode**: Background L=0.98, Text L=0.20 (difference: 0.78 ✓)
**Dark mode**: Background L=0.12, Text L=0.90 (difference: 0.78 ✓)

## Accessibility Standards

All color combinations meet WCAG AA standards:
- **Normal text**: ≥0.40 lightness difference
- **Large text**: ≥0.35 lightness difference  
- **UI components**: ≥0.30 lightness difference

Test using the lightness difference formula:
```
contrast = abs(L1 - L2)
```

## Color Testing Checklist

- [ ] Test in light and dark modes
- [ ] Verify WCAG contrast ratios
- [ ] Check on different displays (P3, sRGB)
- [ ] Test with color blindness simulators
- [ ] Validate with design team

Phase 7: Tooling and Automation

Automated Contrast Checking

// contrast-checker.js
function checkContrast(lightness1, lightness2, level = 'AA') {
  const diff = Math.abs(lightness1 - lightness2);
  
  const thresholds = {
    'AAA-large': 0.35,
    'AA-normal': 0.40,
    'AAA-normal': 0.55,
  };
  
  return diff >= thresholds[level];
}

// Usage
const bgLightness = 0.15;
const textLightness = 0.90;

console.log(checkContrast(bgLightness, textLightness, 'AA-normal'));
// true - passes WCAG AA ✓

Color Token Generator

// generate-tokens.js
function generateScale(hue, name) {
  const steps = [
    { name: '50', l: 0.97, c: 0.05 },
    { name: '100', l: 0.93, c: 0.08 },
    { name: '200', l: 0.85, c: 0.12 },
    { name: '300', l: 0.75, c: 0.15 },
    { name: '400', l: 0.65, c: 0.18 },
    { name: '500', l: 0.55, c: 0.20 },
    { name: '600', l: 0.45, c: 0.20 },
    { name: '700', l: 0.35, c: 0.18 },
    { name: '800', l: 0.25, c: 0.15 },
    { name: '900', l: 0.15, c: 0.10 },
  ];
  
  return steps.map(step => ({
    token: `--${name}-${step.name}`,
    value: `oklch(${step.l} ${step.c} ${hue})`
  }));
}

// Generate CSS
const primary = generateScale(250, 'primary');
primary.forEach(({ token, value }) => {
  console.log(`${token}: ${value};`);
});

Phase 8: Integration with Frameworks

React Example

// theme.ts
export const theme = {
  colors: {
    primary: {
      50: 'oklch(0.97 0.05 250)',
      500: 'oklch(0.55 0.20 250)',
      900: 'oklch(0.15 0.10 250)',
    },
    semantic: {
      background: 'var(--mode-bg)',
      text: 'var(--mode-text)',
      interactive: 'var(--interactive-default)',
    }
  }
} as const;

// Button.tsx
import { theme } from './theme';

export const Button = ({ variant = 'primary', children }) => {
  const styles = {
    primary: {
      background: theme.colors.primary[500],
      color: 'white',
    },
    secondary: {
      background: theme.colors.semantic.background,
      color: theme.colors.semantic.text,
    }
  };
  
  return (
    <button style={styles[variant]}>
      {children}
    </button>
  );
};

Vue Example

<!-- Button.vue -->
<template>
  <button :class="['button', `button-${variant}`]">
    <slot />
  </button>
</template>

<script setup>
defineProps({
  variant: {
    type: String,
    default: 'primary',
    validator: (value) => ['primary', 'secondary', 'ghost'].includes(value)
  }
});
</script>

<style scoped>
.button {
  padding: 0.75rem 1.5rem;
  border-radius: 0.5rem;
  font-weight: 500;
  transition: all 200ms;
}

.button-primary {
  background: var(--button-primary-bg);
  color: var(--button-primary-text);
}

.button-primary:hover {
  background: var(--button-primary-bg-hover);
}
</style>

Svelte Example

<!-- Button.svelte -->
<script lang="ts">
  export let variant: 'primary' | 'secondary' | 'ghost' = 'primary';
</script>

<button class="button button-{variant}">
  <slot />
</button>

<style>
  .button {
    padding: 0.75rem 1.5rem;
    border-radius: 0.5rem;
    font-weight: 500;
    transition: all 200ms;
  }

  .button-primary {
    background: var(--button-primary-bg);
    color: var(--button-primary-text);
  }

  .button-primary:hover {
    background: var(--button-primary-bg-hover);
  }
</style>

Phase 9: Maintenance and Versioning

Version Control

# Color System Changelog

## v2.1.0 (2024-12-10)
### Added
- New `--surface-accent` token for subtle brand backgrounds
- High contrast theme support

### Changed
- Increased chroma in primary-500 from 0.18 to 0.20 for better vibrancy
- Adjusted dark mode text from L=0.85 to L=0.90 for better readability

### Deprecated
- `--color-brand-light` - use `--primary-200` instead

## v2.0.0 (2024-12-01)
### Breaking Changes
- Migrated from HSL to OKLCH
- Renamed all color tokens to semantic names

Testing Strategy

// color-tests.js
describe('Color System', () => {
  it('maintains minimum contrast in light mode', () => {
    const bg = getComputedLightness('--mode-bg');      // 0.98
    const text = getComputedLightness('--mode-text');  // 0.20
    
    expect(Math.abs(bg - text)).toBeGreaterThan(0.40);
  });
  
  it('maintains minimum contrast in dark mode', () => {
    document.documentElement.classList.add('dark');
    
    const bg = getComputedLightness('--mode-bg');      // 0.12
    const text = getComputedLightness('--mode-text');  // 0.90
    
    expect(Math.abs(bg - text)).toBeGreaterThan(0.40);
  });
  
  it('has consistent lightness steps', () => {
    const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
    const lightnesses = shades.map(shade => 
      getComputedLightness(`--primary-${shade}`)
    );
    
    // Check approximately 0.10 increments
    for (let i = 1; i < lightnesses.length; i++) {
      const diff = Math.abs(lightnesses[i-1] - lightnesses[i]);
      expect(diff).toBeCloseTo(0.10, 1);
    }
  });
});

Best Practices Summary

  1. Start with Brand Hues - Define 3-5 core hues first
  2. Use 0.10 Lightness Steps - Ensures perceptual uniformity
  3. Reduce Chroma at Extremes - Very light/dark shades need less saturation
  4. Zero Chroma for Neutrals - Avoid unintended color casts
  5. Create Semantic Layers - Raw → Semantic → Component tokens
  6. Document Everything - Usage guidelines, accessibility notes
  7. Automate Testing - Contrast checking, visual regression
  8. Version Carefully - Treat design system as a product
  9. Test Across Devices - Different displays render colors differently
  10. Collaborate with Designers - Regular sync on color updates

Common Pitfalls

❌ Using Raw Colors in Components

/* Bad */
.button {
  background: oklch(0.55 0.20 250);
}

/* Good */
.button {
  background: var(--interactive-default);
}

❌ Forgetting Dark Mode Adjustments

/* Bad - interactive elements look wrong */
.dark {
  --interactive-default: var(--primary-600); /* Too dark! */
}

/* Good - lighter for dark backgrounds */
.dark {
  --interactive-default: var(--primary-500);
}

❌ Inconsistent Naming

/* Bad */
--blue-main: ...
--primary-color: ...
--brandBlue: ...

/* Good */
--primary-500: ...
--interactive-default: ...
--button-primary-bg: ...

Conclusion

Building a design system with OKLCH provides unprecedented control over color consistency, accessibility, and maintainability. The perceptual uniformity simplifies decisions, makes dark mode effortless, and ensures your colors look great across all devices.

Key takeaway: Layer your tokens (Palette → Semantic → Component) for maximum flexibility and minimum maintenance burden.

Next steps: