Master color theory fundamentals โ HSL color model, color harmony rules, and psychological associations โ and build a production-ready CSS custom property color system that maintains WCAG AA accessibility across light and dark themes.
Color is the most emotionally powerful design tool โ and the most frequently misused one. Developers who pick colors by intuition end up with interfaces that fail accessibility checks, clash visually, and communicate unintended emotional messages. Color theory gives you a systematic framework for making color decisions that are both aesthetically coherent and functionally effective.
In UI design, color serves four distinct functions: brand identity (recognition), semantic meaning (red = danger, green = success), hierarchy (dark = prominent, muted = secondary), and mood (warm = energetic, cool = calm, neutral = professional). A well-designed color system handles all four functions simultaneously through a small set of intentional choices.
This lesson builds a complete CSS color system using the HSL color model โ the most intuitive system for generating systematic palettes โ and covers the color harmony rules that ensure your chosen colors work together harmoniously.
HSL (Hue, Saturation, Lightness) is the color model closest to how humans perceive and describe color. Unlike hex codes (#2563eb) or RGB (37, 99, 235), HSL values are immediately readable and manipulable:
Why HSL for systematic UI palettes: to create a 10-shade scale from a brand color, simply adjust the lightness in equal increments while keeping hue and saturation constant. This produces a coherent family of tones.
Building a 10-shade blue palette from HSL(220, 90%, 56%):
1:root {
2 /* Blue palette โ 10 shades, same hue (220) and saturation (90%) */
3 /* Only lightness changes: 97% (lightest) down to 15% (darkest) */
4 --blue-50: hsl(220, 100%, 97%); /* near white โ background tints */
5 --blue-100: hsl(220, 96%, 93%); /* light background */
6 --blue-200: hsl(220, 94%, 87%); /* hover states on light bg */
7 --blue-300: hsl(220, 90%, 78%); /* disabled or decorative */
8 --blue-400: hsl(220, 90%, 67%); /* light interactive */
9 --blue-500: hsl(220, 90%, 56%); /* brand primary */
10 --blue-600: hsl(220, 90%, 46%); /* hover on primary */
11 --blue-700: hsl(220, 88%, 37%); /* active/pressed */
12 --blue-800: hsl(220, 85%, 27%); /* dark background accents */
13 --blue-900: hsl(220, 80%, 15%); /* near black โ darkest */
14
15 /* Semantic assignments from the palette */
16 --color-brand: var(--blue-500);
17 --color-brand-hover: var(--blue-600);
18 --color-brand-active: var(--blue-700);
19 --color-brand-subtle: var(--blue-50);
20 --color-brand-text: var(--blue-800); /* brand color on white bg, AA compliant */
21}The key insight: changing lightness from 97% to 15% while keeping hue and saturation consistent guarantees every shade looks like it belongs to the same family.
Color harmony rules describe which hue combinations create visually balanced palettes. UI design uses four harmony types:
Building a UI accent color system using complementary harmony:
1:root {
2 /* Primary: blue HSL(220, 90%, 56%) */
3 --color-primary: hsl(220, 90%, 56%);
4 --color-primary-hover: hsl(220, 90%, 46%);
5
6 /* Accent: complement of blue is orange HSL(40, 90%, 56%) */
7 /* Used sparingly โ badges, highlights, key stats only */
8 --color-accent: hsl(40, 90%, 52%);
9 --color-accent-text: hsl(40, 85%, 35%); /* AA compliant on white */
10 --color-accent-subtle: hsl(40, 100%, 96%);
11
12 /* Semantic colors โ independent of brand harmony */
13 --color-success: hsl(142, 72%, 35%); /* green */
14 --color-warning: hsl(38, 92%, 44%); /* amber */
15 --color-danger: hsl(0, 84%, 47%); /* red */
16 --color-info: hsl(199, 90%, 40%); /* cyan */
17}
18
19/* Accent used on a stat badge โ stands out against blue UI */
20.stat-badge {
21 display: inline-flex;
22 align-items: center;
23 gap: 0.25rem;
24 padding: 0.25rem 0.625rem;
25 background: var(--color-accent-subtle);
26 color: var(--color-accent-text);
27 border-radius: 9999px;
28 font-size: 0.75rem;
29 font-weight: 700;
30}WCAG (Web Content Accessibility Guidelines) defines minimum contrast requirements to ensure text is readable for users with low vision or color blindness. The two key thresholds:
Contrast ratio calculation uses relative luminance โ a measure of how much light the color emits relative to white.
Practical contrast verification (test your palette):
1// Relative luminance calculation (WCAG formula)
2function relativeLuminance(r, g, b) {
3 const [rs, gs, bs] = [r, g, b].map(c => {
4 const s = c / 255;
5 return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
6 });
7 return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
8}
9
10function contrastRatio(l1, l2) {
11 const lighter = Math.max(l1, l2);
12 const darker = Math.min(l1, l2);
13 return (lighter + 0.05) / (darker + 0.05);
14}
15
16// Example: Does #2563eb on white (#ffffff) pass WCAG AA?
17// #2563eb = rgb(37, 99, 235)
18const blueL = relativeLuminance(37, 99, 235); // 0.133
19const whiteL = relativeLuminance(255, 255, 255); // 1.0
20const ratio = contrastRatio(whiteL, blueL); // (1.05) / (0.183) = 5.74:1
21// Result: 5.74:1 โ PASSES WCAG AA (requires 4.5:1)Safe text color combinations for common scenarios:
1:root {
2 /* Verified contrast ratios on white background */
3 --text-900: hsl(220, 30%, 10%); /* 19.5:1 โ AAA โ */
4 --text-700: hsl(220, 20%, 25%); /* 11.2:1 โ AAA โ */
5 --text-500: hsl(220, 15%, 45%); /* 4.8:1 โ AA โ */
6 --text-400: hsl(220, 10%, 60%); /* 2.8:1 โ FAIL for small text */
7 /* --text-400 is only safe for large text (18px+) or decorative text */
8}
9
10/* Blue on white โ verified passes AA */
11.link { color: hsl(220, 90%, 46%); } /* --blue-600: 5.74:1 on white โ */
12
13/* White on blue โ primary button must pass AA */
14.btn-primary {
15 background: hsl(220, 90%, 46%); /* --blue-600 */
16 color: white; /* white on --blue-600: 5.74:1 โ */
17}A CSS custom property color system makes dark mode straightforward: define the color roles in :root for light mode, then redefine them inside a prefers-color-scheme: dark media query or a [data-theme="dark"] selector. Components reference roles, not raw colors, so they automatically adapt.
1/* Light mode โ default */
2:root {
3 --color-bg: hsl(0, 0%, 100%); /* page background */
4 --color-bg-alt: hsl(220, 20%, 97%); /* alternate surface */
5 --color-surface: hsl(0, 0%, 100%); /* card/panel surface */
6 --color-surface-alt: hsl(220, 20%, 95%); /* hover/active surface */
7 --color-border: hsl(220, 15%, 90%); /* borders and dividers */
8 --color-text-primary: hsl(220, 30%, 10%); /* body text */
9 --color-text-secondary: hsl(220, 15%, 40%); /* secondary text */
10 --color-text-muted: hsl(220, 10%, 62%); /* meta, captions */
11 --color-brand: hsl(220, 90%, 56%); /* brand primary */
12 --color-brand-hover: hsl(220, 90%, 46%);
13}
14
15/* Dark mode โ swap surfaces and text, keep brand hue but adjust lightness */
16@media (prefers-color-scheme: dark) {
17 :root {
18 --color-bg: hsl(222, 47%, 10%);
19 --color-bg-alt: hsl(222, 47%, 13%);
20 --color-surface: hsl(222, 47%, 15%);
21 --color-surface-alt: hsl(222, 47%, 20%);
22 --color-border: hsl(222, 30%, 25%);
23 --color-text-primary: hsl(220, 20%, 96%);
24 --color-text-secondary: hsl(220, 15%, 75%);
25 --color-text-muted: hsl(220, 10%, 55%);
26 --color-brand: hsl(220, 90%, 65%); /* lighter in dark mode for contrast */
27 --color-brand-hover: hsl(220, 90%, 72%);
28 }
29}
30
31/* Components reference roles โ automatically correct in both modes */
32.card {
33 background: var(--color-surface);
34 border: 1px solid var(--color-border);
35 border-radius: 0.75rem;
36 padding: 1.5rem;
37}
38.card-title { color: var(--color-text-primary); font-weight: 700; }
39.card-body { color: var(--color-text-secondary); }
40.card-meta { color: var(--color-text-muted); font-size: 0.875rem; }The key principle: never use raw colors directly in component CSS. Always reference a semantic custom property. This is the difference between a system that supports dark mode easily and one that requires rewriting every component.
A course card implementing a full HSL-based color system with WCAG-verified contrast ratios and automatic dark mode via CSS custom properties.
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Color System Demo</title>
7 <style>
8 /* HSL Color Palette โ Blue brand, Orange accent, verified contrast */
9 :root {
10 /* Brand blue scale */
11 --blue-50: hsl(220, 100%, 97%);
12 --blue-100: hsl(220, 96%, 93%);
13 --blue-500: hsl(220, 90%, 56%);
14 --blue-600: hsl(220, 90%, 46%); /* 5.74:1 on white โ AA โ */
15 --blue-900: hsl(220, 80%, 15%);
16
17 /* Semantic roles โ light mode */
18 --color-bg: hsl(220, 20%, 97%);
19 --color-surface: hsl(0, 0%, 100%);
20 --color-border: hsl(220, 15%, 90%);
21 --color-text-primary: hsl(220, 30%, 10%);
22 --color-text-secondary: hsl(220, 15%, 40%);
23 --color-text-muted: hsl(220, 10%, 62%);
24 --color-brand: var(--blue-500);
25 --color-brand-hover: var(--blue-600);
26 --color-brand-subtle: var(--blue-50);
27 --color-brand-text: var(--blue-900);
28
29 /* Semantic status colors */
30 --color-success: hsl(142, 72%, 35%);
31 --color-success-subtle: hsl(142, 72%, 95%);
32 --color-warning: hsl(38, 92%, 44%);
33 --color-warning-subtle: hsl(38, 100%, 95%);
34 }
35
36 @media (prefers-color-scheme: dark) {
37 :root {
38 --color-bg: hsl(222, 47%, 10%);
39 --color-surface: hsl(222, 47%, 15%);
40 --color-border: hsl(222, 30%, 25%);
41 --color-text-primary: hsl(220, 20%, 96%);
42 --color-text-secondary: hsl(220, 15%, 70%);
43 --color-text-muted: hsl(220, 10%, 52%);
44 --color-brand: hsl(220, 90%, 65%);
45 --color-brand-hover: hsl(220, 90%, 72%);
46 --color-brand-subtle: hsl(220, 60%, 18%);
47 --color-brand-text: hsl(220, 90%, 80%);
48 --color-success-subtle: hsl(142, 40%, 15%);
49 --color-warning-subtle: hsl(38, 50%, 15%);
50 }
51 }
52
53 *, *::before, *::after { box-sizing: border-box; }
54 body {
55 margin: 0;
56 background: var(--color-bg);
57 font-family: system-ui, sans-serif;
58 color: var(--color-text-primary);
59 padding: 2rem;
60 }
61
62 .cards-grid {
63 display: grid;
64 grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
65 gap: 1.5rem;
66 max-width: 960px;
67 margin: 0 auto;
68 }
69
70 /* Card โ uses only semantic color roles, automatically correct in dark mode */
71 .course-card {
72 background: var(--color-surface);
73 border: 1px solid var(--color-border);
74 border-radius: 0.875rem;
75 overflow: hidden;
76 transition: box-shadow 200ms ease;
77 }
78 .course-card:hover { box-shadow: 0 4px 16px hsl(220 90% 56% / 0.12); }
79 .course-card-img {
80 width: 100%;
81 aspect-ratio: 16/9;
82 background: var(--color-brand-subtle);
83 display: flex;
84 align-items: center;
85 justify-content: center;
86 color: var(--color-brand);
87 font-size: 2rem;
88 }
89 .course-card-body { padding: 1.25rem; }
90
91 /* Badge row */
92 .badge-row { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
93 .badge {
94 display: inline-flex;
95 align-items: center;
96 padding: 0.2rem 0.5rem;
97 border-radius: 9999px;
98 font-size: 0.6875rem;
99 font-weight: 700;
100 letter-spacing: 0.04em;
101 text-transform: uppercase;
102 }
103 .badge-beginner { background: var(--color-success-subtle); color: var(--color-success); }
104 .badge-new { background: var(--color-warning-subtle); color: var(--color-warning); }
105 .badge-popular { background: var(--color-brand-subtle); color: var(--color-brand-text); }
106
107 .course-card-title {
108 font-size: 1.0625rem;
109 font-weight: 700;
110 color: var(--color-text-primary);
111 margin: 0 0 0.375rem;
112 line-height: 1.35;
113 }
114 .course-card-meta {
115 font-size: 0.8125rem;
116 color: var(--color-text-muted);
117 margin: 0 0 1rem;
118 }
119 .course-card-btn {
120 display: block;
121 width: 100%;
122 padding: 0.625rem 1rem;
123 background: var(--color-brand);
124 color: white;
125 border: none;
126 border-radius: 0.5rem;
127 font-size: 0.9375rem;
128 font-weight: 600;
129 cursor: pointer;
130 text-align: center;
131 transition: background-color 150ms ease;
132 font-family: inherit;
133 }
134 .course-card-btn:hover { background: var(--color-brand-hover); }
135 .course-card-btn:focus-visible {
136 outline: 2px solid var(--color-brand);
137 outline-offset: 2px;
138 }
139 </style>
140</head>
141<body>
142 <div class="cards-grid">
143 <article class="course-card">
144 <div class="course-card-img" aria-hidden="true">โ</div>
145 <div class="course-card-body">
146 <div class="badge-row">
147 <span class="badge badge-beginner">Beginner</span>
148 <span class="badge badge-popular">Popular</span>
149 </div>
150 <h2 class="course-card-title">React Fundamentals</h2>
151 <p class="course-card-meta">24 lessons ยท 8 hours</p>
152 <button class="course-card-btn" type="button">Start Course</button>
153 </div>
154 </article>
155 <article class="course-card">
156 <div class="course-card-img" aria-hidden="true">๐จ</div>
157 <div class="course-card-body">
158 <div class="badge-row">
159 <span class="badge badge-beginner">Beginner</span>
160 <span class="badge badge-new">New</span>
161 </div>
162 <h2 class="course-card-title">CSS Design Systems</h2>
163 <p class="course-card-meta">18 lessons ยท 6 hours</p>
164 <button class="course-card-btn" type="button">Start Course</button>
165 </div>
166 </article>
167 </div>
168</body>
169</html>This color system uses only semantic custom properties in component CSS โ never raw color values. The card's background is var(--color-surface), not white or hsl(0,0%,100%). When the prefers-color-scheme: dark media query activates, every component recolors automatically without a single component-level change. The badge colors use semantic role variables (--color-success-subtle, --color-brand-subtle) that have dark mode counterparts defined. The button's brand color on white background has been verified at 5.74:1 contrast ratio โ WCAG AA compliant.
The next lesson covers typography basics for UI โ serif versus sans-serif, type scale, line-height, letter-spacing, and font pairing โ with CSS implementations that create readable, professional typographic systems.
Your progress is saved in this browser, on this device only.