Building a Design System with CSS Custom Properties and HSL
A design system lives or dies by its color palette. Too many hardcoded color values, and changing your brand color requires a find-and-replace across hundreds of files. Too little structure, and developers make inconsistent color decisions that accumulate into visual noise. The combination of HSL color values and CSS custom properties gives you a color system that's consistent, themeable, and almost self-documenting.
Find your perfect colors: Use our RGB/HEX/HSL Converter to get the HSL values for any hex color from your brand guidelines.
Open Color Converter โTable of Contents
Why HSL for Design Systems
HSL (Hue, Saturation, Lightness) maps to how designers think about color. When a designer says "make that button slightly darker on hover", that's a lightness change. When they say "use a muted version of the brand color for disabled states", that's a saturation change. With HSL, you can implement these instructions by changing a single number. With HEX or RGB, you'd need to know the new exact value.
Defining Your Color Scale
Start by picking your brand hue (0โ360) and building a scale of 9โ11 lightness values:
:root {
/* Brand hue and saturation โ single source of truth */
--brand-h: 221;
--brand-s: 83%;
/* Full lightness scale */
--brand-50: hsl(var(--brand-h), var(--brand-s), 97%);
--brand-100: hsl(var(--brand-h), var(--brand-s), 93%);
--brand-200: hsl(var(--brand-h), var(--brand-s), 85%);
--brand-300: hsl(var(--brand-h), var(--brand-s), 74%);
--brand-400: hsl(var(--brand-h), var(--brand-s), 62%);
--brand-500: hsl(var(--brand-h), var(--brand-s), 53%); /* base */
--brand-600: hsl(var(--brand-h), var(--brand-s), 45%);
--brand-700: hsl(var(--brand-h), var(--brand-s), 37%);
--brand-800: hsl(var(--brand-h), var(--brand-s), 28%);
--brand-900: hsl(var(--brand-h), var(--brand-s), 19%);
--brand-950: hsl(var(--brand-h), var(--brand-s), 12%);
}
This gives you a full palette generated from one hue and one saturation value. Change --brand-h and the entire system recolors. This is the approach Tailwind CSS, Radix UI, and most modern design systems use.
Semantic Token Layer
The scale is your raw palette. Your semantic tokens give these values meaning:
:root {
/* Semantic tokens reference scale tokens */
--color-primary: var(--brand-500);
--color-primary-hover: var(--brand-600);
--color-primary-active: var(--brand-700);
--color-primary-subtle: var(--brand-100);
--color-primary-on: white; /* text on primary background */
--color-bg: white;
--color-bg-raised: var(--brand-50);
--color-border: var(--brand-200);
--color-text: var(--brand-900);
--color-text-muted: var(--brand-600);
}
/* Components use semantic tokens, not scale tokens */
.btn-primary {
background: var(--color-primary);
color: var(--color-primary-on);
border: 1px solid var(--color-primary);
}
.btn-primary:hover {
background: var(--color-primary-hover);
}
Dark Mode in 10 Lines
Because everything references semantic tokens, dark mode is just redefining those tokens:
@media (prefers-color-scheme: dark) {
:root {
--color-bg: var(--brand-950);
--color-bg-raised: var(--brand-900);
--color-border: var(--brand-800);
--color-text: var(--brand-50);
--color-text-muted: var(--brand-400);
--color-primary: var(--brand-400); /* lighter in dark mode */
--color-primary-hover: var(--brand-300);
}
}
No component styles need to change. Every element that uses semantic tokens automatically updates.
Neutral Colors
Most design systems need a neutral (grey) scale alongside the brand color. Use the same HSL approach with very low saturation โ a "warm grey" keeps a tiny bit of the brand hue:
:root {
--neutral-h: 221;
--neutral-s: 10%; /* very low saturation = near-grey with warm tint */
--neutral-100: hsl(var(--neutral-h), var(--neutral-s), 97%);
--neutral-500: hsl(var(--neutral-h), var(--neutral-s), 55%);
--neutral-900: hsl(var(--neutral-h), var(--neutral-s), 12%);
/* ... full scale */
}
Checking Contrast
A color system is only as good as its accessibility. For every text-on-background combination, verify the contrast ratio meets WCAG AA (4.5:1 for normal text, 3:1 for large text). Lightness in HSL gives you a rough guide โ text that's 90+ lightness on a background that's 10 lightness will always pass, but mid-range values need checking. Use a contrast checker to validate your semantic token pairings before shipping.
Moving Beyond HSL: oklch in 2026
HSL has been the go-to color space for design systems since CSS3. But it has a known weakness: equal lightness values in HSL do not look equally bright perceptually. Yellow at hsl(60, 100%, 50%) appears much lighter than blue at hsl(240, 100%, 50%).
oklch solves this. It is a perceptually uniform color space where equal L values genuinely look equally bright, regardless of hue. All major browsers have supported it since 2023. For new design systems or major token updates, oklch is now the recommended choice:
/* HSL โ old approach */
:root {
--color-primary: hsl(220, 80%, 55%);
--color-primary-light: hsl(220, 80%, 75%);
--color-primary-dark: hsl(220, 80%, 35%);
}
/* oklch โ modern approach (perceptually consistent) */
:root {
--hue: 250;
--color-primary: oklch(55% 0.18 var(--hue));
--color-primary-light: oklch(75% 0.12 var(--hue));
--color-primary-dark: oklch(35% 0.22 var(--hue));
}
The oklch approach makes it trivially easy to create accessible color palettes โ all tones at the same L value will appear equally bright, making contrast ratio calculations more predictable. For teams starting a new design system in 2025+, oklch is the recommended color space.
