CSS Variables & OKLCH: Dynamic Theming
Master dynamic theming by combining CSS Custom Properties with OKLCH. Learn to create flexible, runtime-adjustable color systems for dark mode, brand switching, and user customization.
CSS Variables & OKLCH: Dynamic Theming Guide
Master the art of dynamic theming by combining CSS Custom Properties with OKLCH colors. This guide shows you how to create flexible, runtime-adjustable color systems that adapt to user preferences, brand requirements, and accessibility needs.
Why CSS Variables + OKLCH?
Runtime Flexibility: Change colors without recompiling CSS
Theme Switching: Instant dark mode, brand themes, accessibility modes
Calculation Support: Use calc() for programmatic color generation
Scoping: Component-level color overrides
Performance: No JavaScript required for basic theming
Foundation: Variable Architecture
Three-Layer System
:root {
/* ============================================
Layer 1: Primitive Values (Raw Numbers)
Never changed, form the foundation
============================================ */
--lightness-max: 0.98;
--lightness-high: 0.85;
--lightness-mid: 0.55;
--lightness-low: 0.25;
--lightness-min: 0.12;
--chroma-vibrant: 0.20;
--chroma-moderate: 0.12;
--chroma-subtle: 0.05;
--chroma-neutral: 0.00;
--hue-brand: 250;
--hue-accent: 340;
--hue-success: 145;
--hue-warning: 85;
--hue-error: 25;
/* ============================================
Layer 2: Palette Colors (Assembled OKLCH)
Combine primitives into full color values
============================================ */
--color-primary-500: oklch(var(--lightness-mid) var(--chroma-vibrant) var(--hue-brand));
--color-primary-600: oklch(0.45 var(--chroma-vibrant) var(--hue-brand));
--color-primary-700: oklch(0.35 0.18 var(--hue-brand));
--color-neutral-50: oklch(var(--lightness-max) var(--chroma-neutral) 0);
--color-neutral-500: oklch(var(--lightness-mid) 0.04 270);
--color-neutral-900: oklch(var(--lightness-min) 0.02 270);
/* ============================================
Layer 3: Semantic Tokens (Meaning-based)
Map palette to component usage
============================================ */
--bg-primary: var(--color-neutral-50);
--bg-elevated: var(--color-neutral-50);
--text-primary: var(--color-neutral-900);
--text-link: var(--color-primary-600);
--interactive: var(--color-primary-600);
}
Dynamic Theme Switching
Dark Mode Implementation
:root {
/* Light mode defaults */
--theme-bg-l: var(--lightness-max);
--theme-text-l: var(--lightness-low);
--theme-surface-l: 0.95;
}
/* Dark mode - swap lightness values */
@media (prefers-color-scheme: dark) {
:root {
--theme-bg-l: var(--lightness-min);
--theme-text-l: 0.90;
--theme-surface-l: 0.18;
}
}
/* Apply theme-aware values */
:root {
--bg: oklch(var(--theme-bg-l) 0.02 270);
--text: oklch(var(--theme-text-l) 0.03 270);
--surface: oklch(var(--theme-surface-l) 0.03 270);
}
Manual Theme Toggle
<!-- Theme switcher -->
<button id="theme-toggle">Toggle Theme</button>
<style>
/* Light theme (default) */
:root {
--mode: 'light';
--bg-l: 0.98;
--text-l: 0.20;
}
/* Dark theme */
:root[data-theme="dark"] {
--mode: 'dark';
--bg-l: 0.12;
--text-l: 0.90;
}
/* Apply colors */
body {
background: oklch(var(--bg-l) 0.02 270);
color: oklch(var(--text-l) 0.03 270);
transition: background 300ms, color 300ms;
}
</style>
<script>
const toggle = document.getElementById('theme-toggle');
toggle.addEventListener('click', () => {
const root = document.documentElement;
const current = root.getAttribute('data-theme');
root.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark');
});
</script>
Brand Theme Switching
Switch between different brand identities:
:root {
/* Default brand */
--brand-hue: 250;
--brand-name: 'blue';
}
/* Alternative brands */
[data-brand="red"] {
--brand-hue: 25;
--brand-name: 'red';
}
[data-brand="green"] {
--brand-hue: 145;
--brand-name: 'green';
}
[data-brand="purple"] {
--brand-hue: 300;
--brand-name: 'purple';
}
/* All components automatically update */
:root {
--primary-500: oklch(0.55 0.20 var(--brand-hue));
--primary-600: oklch(0.45 0.20 var(--brand-hue));
--primary-700: oklch(0.35 0.18 var(--brand-hue));
}
.button-primary {
background: var(--primary-600);
}
Advanced: Calculated Color Variations
Automatic Tints and Shades
:root {
/* Base color */
--base-l: 0.55;
--base-c: 0.20;
--base-h: 250;
/* Calculated variations using calc() */
--color-lighter: oklch(calc(var(--base-l) + 0.20) var(--base-c) var(--base-h));
--color-base: oklch(var(--base-l) var(--base-c) var(--base-h));
--color-darker: oklch(calc(var(--base-l) - 0.20) var(--base-c) var(--base-h));
/* More vibrant */
--color-vibrant: oklch(var(--base-l) calc(var(--base-c) * 1.2) var(--base-h));
/* Desaturated */
--color-muted: oklch(var(--base-l) calc(var(--base-c) * 0.5) var(--base-h));
}
Dynamic Contrast Adjustment
:root {
/* Ensure accessible contrast dynamically */
--bg-l: 0.98;
--text-l: calc(var(--bg-l) - 0.75); /* Always 0.75 darker than background */
background: oklch(var(--bg-l) 0.00 0);
color: oklch(var(--text-l) 0.03 270);
}
/* Dark mode - inverse calculation */
@media (prefers-color-scheme: dark) {
:root {
--bg-l: 0.12;
--text-l: calc(var(--bg-l) + 0.75); /* Always 0.75 lighter */
}
}
Component-Scoped Theming
Override colors at component level:
/* Global theme */
:root {
--button-bg: oklch(0.55 0.20 250);
--button-text: oklch(0.98 0.00 0);
}
/* Component override */
.card-premium {
--button-bg: oklch(0.55 0.22 340); /* Pink for premium */
}
.card-eco {
--button-bg: oklch(0.60 0.20 145); /* Green for eco */
}
/* Button automatically adapts to context */
.button {
background: var(--button-bg);
color: var(--button-text);
}
Accessibility Themes
High Contrast Mode
:root {
--normal-contrast: 0.75;
--high-contrast: 0.90;
/* Default */
--contrast-ratio: var(--normal-contrast);
}
/* High contrast preference */
@media (prefers-contrast: more) {
:root {
--contrast-ratio: var(--high-contrast);
}
}
/* Manual toggle */
[data-contrast="high"] {
--contrast-ratio: var(--high-contrast);
}
/* Apply contrast-aware colors */
:root {
--bg-l: 0.98;
--text-l: calc(var(--bg-l) - var(--contrast-ratio));
background: oklch(var(--bg-l) 0.00 0);
color: oklch(var(--text-l) 0.02 270);
}
Reduced Motion
:root {
--transition-duration: 200ms;
}
@media (prefers-reduced-motion: reduce) {
:root {
--transition-duration: 0ms;
}
}
.button {
transition: background var(--transition-duration);
}
User Customization
Allow users to customize brand colors:
<!-- Color customizer -->
<div class="customizer">
<label>
Brand Hue:
<input type="range" min="0" max="360" value="250" id="hue-slider">
<output id="hue-value">250</output>
</label>
<label>
Chroma:
<input type="range" min="0" max="0.35" step="0.01" value="0.20" id="chroma-slider">
<output id="chroma-value">0.20</output>
</label>
</div>
<script>
const hueSlider = document.getElementById('hue-slider');
const chromaSlider = document.getElementById('chroma-slider');
const hueValue = document.getElementById('hue-value');
const chromaValue = document.getElementById('chroma-value');
hueSlider.addEventListener('input', (e) => {
const hue = e.target.value;
document.documentElement.style.setProperty('--brand-hue', hue);
hueValue.textContent = hue;
});
chromaSlider.addEventListener('input', (e) => {
const chroma = e.target.value;
document.documentElement.style.setProperty('--brand-chroma', chroma);
chromaValue.textContent = chroma;
});
</script>
<style>
:root {
--brand-hue: 250;
--brand-chroma: 0.20;
--primary-500: oklch(0.55 var(--brand-chroma) var(--brand-hue));
}
</style>
Production Patterns
Multi-Brand Application
/* Base system */
:root {
/* Shared neutrals */
--neutral-50: oklch(0.97 0.00 0);
--neutral-900: oklch(0.15 0.02 270);
}
/* Brand A (default) */
:root,
[data-brand="brand-a"] {
--brand-hue: 250;
--brand-chroma: 0.20;
--brand-name: 'Brand A';
--primary-500: oklch(0.55 var(--brand-chroma) var(--brand-hue));
}
/* Brand B */
[data-brand="brand-b"] {
--brand-hue: 25;
--brand-chroma: 0.22;
--brand-name: 'Brand B';
--primary-500: oklch(0.58 var(--brand-chroma) var(--brand-hue));
}
/* Brand C */
[data-brand="brand-c"] {
--brand-hue: 145;
--brand-chroma: 0.20;
--brand-name: 'Brand C';
--primary-500: oklch(0.60 var(--brand-chroma) var(--brand-hue));
}
State-Based Theming
/* Loading state - desaturated */
.loading {
--state-chroma-multiplier: 0.5;
}
/* Error state - red tint */
.error {
--state-hue: 25;
--state-chroma-multiplier: 1.0;
}
/* Success state - green tint */
.success {
--state-hue: 145;
--state-chroma-multiplier: 1.0;
}
/* Apply state-aware colors */
.card {
--card-hue: var(--state-hue, var(--brand-hue));
--card-chroma: calc(var(--brand-chroma) * var(--state-chroma-multiplier, 1));
border-left: 4px solid oklch(0.55 var(--card-chroma) var(--card-hue));
}
Performance Optimization
Minimize Recalculations
/* ❌ Bad - recalculates on every usage */
.element {
background: oklch(calc(var(--base-l) + 0.20) var(--base-c) var(--base-h));
}
/* ✅ Good - calculate once, reuse */
:root {
--color-light: oklch(calc(var(--base-l) + 0.20) var(--base-c) var(--base-h));
}
.element {
background: var(--color-light);
}
Scope Variables Appropriately
/* ❌ Bad - global pollution */
.button {
--temp-color: oklch(0.55 0.20 250);
background: var(--temp-color);
}
/* ✅ Good - scoped where needed */
.button-group {
--group-primary: oklch(0.55 0.20 250);
}
.button-group .button {
background: var(--group-primary);
}
Browser Support and Fallbacks
/* Fallback for older browsers */
.element {
/* HSL fallback */
background: hsl(220deg 80% 55%);
/* OKLCH override for modern browsers */
background: oklch(0.60 0.20 250);
}
/* Feature detection */
@supports (color: oklch(0 0 0)) {
:root {
--supports-oklch: true;
}
}
@supports not (color: oklch(0 0 0)) {
:root {
/* Use HSL/RGB fallbacks */
--primary-500: hsl(220deg 80% 55%);
}
}
Debugging Tips
/* Visual debug mode */
[data-debug="true"] {
--debug-border: 2px solid red;
}
:root {
--debug-border: 0px solid transparent;
}
* {
outline: var(--debug-border);
}
/* Log current theme in DevTools */
:root::before {
content: 'Theme: ' var(--theme-name, 'default') ' | Mode: ' var(--mode, 'light');
position: fixed;
top: 0;
left: 0;
background: black;
color: white;
padding: 0.5rem;
font-family: monospace;
z-index: 10000;
}
Best Practices
- Use Three Layers: Primitives → Palette → Semantic
- Name Descriptively:
--text-primarynot--color-1 - Calculate Once: Avoid repeated
calc()in multiple places - Scope Appropriately: Component vars in components, global vars in
:root - Document Variables: Comment usage and expected values
- Test Themes: Verify all themes meet accessibility standards
- Provide Defaults: Always have fallback values
- Optimize Performance: Minimize CSS variable nesting depth (≤3 levels)
Common Pitfalls
❌ Over-Nesting Variables
/* Bad - 5 levels deep */
:root {
--l1: 0.50;
--l2: var(--l1);
--l3: var(--l2);
--l4: var(--l3);
--color: oklch(var(--l4) 0.20 250);
}
/* Good - direct reference */
:root {
--lightness-mid: 0.50;
--color: oklch(var(--lightness-mid) 0.20 250);
}
❌ Circular References
/* Bad - circular dependency */
:root {
--color-a: var(--color-b);
--color-b: var(--color-a); /* ❌ Circular! */
}
/* Good - linear dependency */
:root {
--color-base: oklch(0.55 0.20 250);
--color-variant: var(--color-base);
}
Conclusion
CSS Variables + OKLCH create a powerful, flexible theming system that adapts to user needs without JavaScript overhead. The combination enables runtime customization, easy dark modes, brand switching, and accessibility enhancements—all while maintaining perceptually uniform colors.
Key takeaway: Structure your variables in layers (Primitives → Palette → Semantic) for maximum flexibility and maintainability.
Next steps: