Pick Better
Colors. Build
Smarter CSS.
Learn color theory the CSS way — from the color wheel to oklch(). Build a complete design system with semantic tokens, spacing, and shadows. Then construct 10 fully interactive website components with zero JavaScript: dropdowns, modals, tabs, accordions, and more.
§ 01 — Color Relationships
Color Wheel & Harmony
Every great color palette comes from understanding the color wheel. Pick a base hue, then derive harmonious partners using one of five relationships. The interactive wheel below shows them all.
/* Color Harmony in CSS HSL: Base hue = H. Derive partners by rotating the hue value. */ /* Complementary — opposite on wheel */ --base: hsl(265, 80%, 60%); /* violet */ --comp: hsl(85, 70%, 50%); /* 265+180=85° lime */ /* Analogous — 30° neighbors */ --ana1: hsl(235, 80%, 60%); --ana2: hsl(295, 80%, 60%); /* Triadic — 120° apart */ --tri1: hsl(25, 80%, 60%); /* 265-240 */ --tri2: hsl(145, 70%, 45%); /* 265-120 */ /* Split-Complementary */ --spl1: hsl(55, 80%, 55%); /* 265+150 */ --spl2: hsl(115, 70%, 50%); /* 265+210 */
§ 02 — Hue · Saturation · Lightness
HSL Deep Dive
HSL is the most intuitive color format for web design. Unlike hex or RGB which describe amounts of light, HSL describes the color itself — making it trivial to lighten, darken, desaturate, or shift hue programmatically.
/* HSL: hsl(H, S%, L%) H = 0-360° position on color wheel S = 0-100% gray → full color L = 0-100% black → white (50% = pure color) */ --brand: hsl(265, 80%, 60%); /* Tints (lighter) — increase L */ --brand-100: hsl(265, 80%, 95%); --brand-200: hsl(265, 80%, 85%); /* Shades (darker) — decrease L */ --brand-700: hsl(265, 80%, 30%); --brand-900: hsl(265, 80%, 10%); /* Muted — decrease S */ --brand-muted: hsl(265, 20%, 60%); /* With alpha — use hsl(...) + / */ --brand-ghost: hsl(265 80% 60% / 0.15); /* Why HSL over hex? hex #9b59ff — no idea what this means hsl(265,80%,60%) — violet, vivid, medium */
§ 03 — From one color to a full system
Building a Palette
A professional palette has one brand/primary color, an accent, a neutral scale (gray), semantic colors (success, warning, danger), and surface colors for backgrounds. All derived from one starting hue.
/* Complete palette derived from one hue (265°) */ :root { /* Brand scale (50-900) */ --brand-50: hsl(265 80% 97%); --brand-100: hsl(265 80% 93%); --brand-400: hsl(265 80% 72%); --brand-500: hsl(265 80% 60%); /* PRIMARY */ --brand-700: hsl(265 80% 35%); --brand-900: hsl(265 80% 12%); /* Neutral scale (same hue, low saturation) */ --neutral-50: hsl(265 8% 97%); --neutral-500:hsl(265 8% 56%); --neutral-900:hsl(265 8% 8% ); /* Semantic (fixed hues, match saturation) */ --success: hsl(135 70% 42%); --warning: hsl(40 95% 52%); --danger: hsl(350 80% 58%); --info: hsl(198 80% 52%); }
§ 04 — WCAG 2.1 Contrast Ratios
Contrast & Accessibility
WCAG requires a minimum contrast ratio of 4.5:1 for normal text (AA) and 7:1 for the AAA level. Large text (18px+ bold) only needs 3:1. The contrast ratio of 21:1 is pure black on white.
Sample Heading Text
The quick brown fox jumps over the lazy dog. This paragraph shows body text at normal size.
/* WCAG 2.1 Requirements ────────────────────────────────────────── AA Normal text ≥ 4.5:1 AA Large text ≥ 3:1 (18px+ or 14px+ bold) AAA Normal text ≥ 7:1 AAA Large text ≥ 4.5:1 UI Components ≥ 3:1 (borders, icons) Common Mistakes: ❌ Gray text on white: #888 on white = 3.5:1 FAIL ❌ Light on light: #ffd700 on #fff = 1.1:1 FAIL ❌ Low-sat color on bg: #9b59ff on #7030d0 = FAIL ✓ #f0eafa on #0a0814 = 16.8:1 PASS AAA ✓ #ff6b2b on #0a0814 = 7.2:1 PASS AAA */ /* Test with browser DevTools: Inspect element → Accessibility → Contrast */ /* Contrast-safe text-on-background patterns: */ .on-dark { color: hsl(0 0% 95%); background: hsl(0 0% 8%); } .on-light { color: hsl(0 0% 10%); background: hsl(0 0% 98%); } .muted { color: hsl(265 20% 65%); } /* ≥4.5:1 on dark */
§ 05 — Modern CSS Color Functions
oklch() & color-mix()
oklch() is perceptually uniform — changing Lightness always looks equally bright, unlike HSL. color-mix() blends two colors in any space. Together, they make dynamic tinting trivial.
/* oklch(L C H [/ alpha]) L = 0-1 lightness (perceptual) C = 0-0.4 chroma (saturation) H = 0-360° hue */ --violet: oklch(0.55 0.22 293); --orange: oklch(0.65 0.2 38 ); --cyan: oklch(0.78 0.12 192); /* Adjust only lightness — same feel */ --violet-light: oklch(0.85 0.22 293); --violet-dark: oklch(0.30 0.22 293); /* color-mix(in space, color1 %, color2) */ --tinted: color-mix(in oklch, var(--violet) 25%, white); --shaded: color-mix(in oklch, var(--violet) 75%, black); --muted: color-mix(in oklch, var(--violet) 60%, gray); /* Relative color syntax (CSS 4) — modify one channel */ --darker: oklch(from var(--violet) calc(l - 0.2) c h); --warmer: oklch(from var(--violet) l c calc(h + 30)); --faded: oklch(from var(--violet) l calc(c * 0.3) h);
§ 06 — Design Token Architecture
Semantic Color Tokens
Don't reference raw color values in your components. Use semantic tokens — names that describe purpose, not appearance. Then you can retheme the whole site by changing only the token definitions.
| Token | Purpose | Dark Value | Light Value |
|---|---|---|---|
--color-primary | Main brand action | #ff6b2b | #d44000 |
--color-surface | Card backgrounds | #18152a | #e8e2f8 |
--color-on-surface | Text on surface | #f0eafa | #160e2e |
--color-border | Dividers, outlines | #241f3a | #ccc5e8 |
--color-success | Confirmed, done | #7ddf64 | #2a8a00 |
--color-danger | Errors, delete | #ff4d7e | #b8002e |
--color-warning | Alerts, caution | #ffc94d | #8a5200 |
--color-muted | Placeholder text | #4a3f72 | #9488bb |
:root { /* ── PRIMITIVE TOKENS (raw values) ─────────── */ --violet-500: hsl(265 80% 60%); --orange-500: hsl(20 100% 59%); --neutral-50: hsl(265 8% 97%); --neutral-900:hsl(265 8% 8% ); } :root[data-theme="dark"] { /* ── SEMANTIC TOKENS (reference primitive) ── */ --color-primary: var(--orange-500); --color-bg: hsl(265 20% 5%); --color-surface: hsl(265 15% 10%); --color-on-surface: var(--neutral-50); --color-muted: hsl(265 15% 50%); --color-border: hsl(265 15% 20%); } :root[data-theme="light"] { --color-primary: hsl(20 100% 42%); /* darker on light */ --color-bg: var(--neutral-50); --color-surface: hsl(265 15% 93%); --color-on-surface: var(--neutral-900); --color-muted: hsl(265 15% 55%); --color-border: hsl(265 15% 82%); }
§ 07 — prefers-color-scheme + manual toggle
Dark / Light Mode
This page itself supports light mode — click the toggle in the sidebar. The right approach combines prefers-color-scheme media query (respects OS preference) with a manual override via a data-theme attribute on <html>.
Dark Mode
Easier on eyes at night. Choose dark surfaces with high-contrast text. Reduce pure black — dark gray (#111) feels more premium than #000. Colored accents need to be bright (high lightness) to pop against dark backgrounds.
Light Mode
Cleaner for reading-heavy content. Use off-white (#faf8ff) not pure white. Your brand color may need to darken (reduce lightness) to pass contrast on light backgrounds. Shadows become more visible and important.
/* METHOD 1 — OS preference auto (no JS) */ @media (prefers-color-scheme: dark) { :root { --bg: hsl(265 20% 5%); --text: hsl(265 50% 95%); } } @media (prefers-color-scheme: light) { :root { --bg: hsl(265 20% 97%); --text: hsl(265 20% 8%); } } /* METHOD 2 — Manual toggle with data-theme */ html[data-theme="dark"] { --bg: #0a0814; --text: #f0eafa; } html[data-theme="light"] { --bg: #faf8ff; --text: #160e2e; } // METHOD 2 — JS toggle const current = html.dataset.theme; html.dataset.theme = current === 'dark' ? 'light' : 'dark'; localStorage.setItem('theme', html.dataset.theme); /* METHOD 3 — CSS-only toggle with checkbox (:has) */ html:has(#light-toggle:checked) { --bg: #faf8ff; --text: #160e2e; } /* Always smooth transitions */ * { transition: background-color .3s, color .3s, border-color .3s; }
§ 08 — Type Scale System
Typography Scale
A type scale uses a ratio to create visual hierarchy. The Major Third (1.25×) is subtle. The Perfect Fifth (1.5×) is dramatic. Use clamp() for fluid type that scales between min and max with the viewport.
/* Major Third scale (1.25×), base 16px */ :root { --text-xs: clamp(0.64rem, 1.2vw, 0.75rem); /* ~10–12px */ --text-sm: clamp(0.75rem, 1.4vw, 0.875rem); /* ~12–14px */ --text-base: clamp(1rem, 1.6vw, 1.125rem); /* ~16–18px */ --text-lg: clamp(1.25rem, 2vw, 1.5rem); /* ~20–24px */ --text-xl: clamp(1.563rem, 2.5vw, 2rem); /* ~25–32px */ --text-2xl: clamp(1.953rem, 3vw, 2.5rem); /* ~31–40px */ --text-3xl: clamp(2.441rem, 4vw, 3.5rem); /* ~39–56px */ --text-4xl: clamp(3.052rem, 5vw, 5rem); /* ~49–80px */ /* Line heights */ --leading-tight: 1.15; /* headlines */ --leading-snug: 1.4; /* subheadings */ --leading-normal:1.7; /* body text */ --leading-loose: 2.0; /* captions */ }
§ 09 — 4px / 8px Grid
Spacing System
All spacing — padding, margin, gap, width — should be multiples of 4px (or an 8px base). This creates visual rhythm. Define a spacing scale as CSS variables so you never type a raw pixel value again.
:root { --space-1: 4px; /* tight gap, icon padding */ --space-2: 8px; /* small gaps */ --space-3: 12px; /* button padding */ --space-4: 16px; /* card padding (base) */ --space-5: 20px; --space-6: 24px; /* section padding */ --space-8: 32px; --space-10: 40px; --space-12: 48px; --space-16: 64px; /* section separator */ --space-24: 96px; /* hero sections */ /* Container widths */ --container-sm: 640px; --container-md: 768px; --container-lg: 1024px; --container-xl: 1280px; } /* Use tokens everywhere */ .card { padding: var(--space-6); } .btn { padding: var(--space-3) var(--space-5); } .layout { gap: var(--space-8); } .page { max-width: var(--container-lg); margin: 0 auto; }
§ 10 — Shadow Depth System
Elevation & Shadows
Shadows convey depth — a consistent elevation system makes your UI feel physically coherent. Use multiple shadow layers for realism, and match shadow color to your background hue, not pure black.
e0
e1
e2
e3
e4
:root { /* Shadow hue matches background (265°) */ --shadow-color: 265 30% 5%; --shadow-1: 0 1px 3px hsl(var(--shadow-color) / 0.30); --shadow-2: 0 4px 12px hsl(var(--shadow-color) / 0.35); --shadow-3: 0 8px 24px hsl(var(--shadow-color) / 0.40), 0 2px 6px hsl(var(--shadow-color) / 0.25); --shadow-4: 0 16px 40px hsl(var(--shadow-color) / 0.45), 0 4px 12px hsl(var(--shadow-color) / 0.30); /* Colored glow shadows */ --glow-orange: 0 0 20px hsl(20 100% 59% / 0.35); --glow-violet: 0 0 24px hsl(265 80% 60% / 0.3); /* Inner shadow */ --inner-shadow: inset 0 1px 3px rgba(0,0,0,.3); }
§ 11 — Zero-JS Interactive Forms
Custom Form Controls in Pure CSS
CSS can completely re-style native form elements. Hide the native control, place a styled replacement right after it in the HTML, and use the adjacent sibling selector (input:checked + .custom) to reflect state.
§ 13 — :target and :has() modal techniques
CSS-Only Modals
Two techniques — the :target hack uses URL hash navigation (the modal overlay shows when its ID matches the URL hash), and the :has() technique uses a hidden checkbox to toggle visibility.
§ 14 — Radio input + sibling selectors
CSS-Only Tabs
The radio button trick: hidden radio inputs track the active tab. The :checked state cascades via sibling selectors to show the right panel. The entire tab component — no JavaScript.
Overview
This is the overview panel. It's shown because the Tab 1 radio input is checked. When you click another tab label, it checks a different radio, which triggers CSS sibling selectors to hide this panel and show another.
Specifications
Specifications content here. This whole tab system is driven by CSS — the #tab2:checked ~ .tab2-content selector makes this visible when Tab 2 is active.
Reviews ★★★★★
"Absolutely brilliant technique — I can't believe this works without JavaScript!" This panel is hidden until Tab 3's radio is checked.
§ 15 — CSS Hover Interactions
Hover Effects & Cards
Compelling hover effects need only CSS — GPU-composited transform and opacity changes are buttery smooth. The mouse-tracking glow uses a CSS custom property set by JavaScript on mousemove.
flip! 🎴
Pure CSS ✨
Radial Glow Card
Move your mouse around inside this card to see the glow follow your cursor. It uses a CSS custom property updated by a single mousemove listener.
§ 16 — Complete Button System
Button Design System
A complete button system covers variant (solid, outlined, ghost), semantic role (primary, danger, success), size (sm → xl), state (loading, disabled, icon-only), and hover micro-interactions.
§ 17 — Visual Treatment Techniques
Glass, Grain & Glow
Glassmorphism — frosted glass with backdrop-filter:blur(). Grain — SVG noise as a pseudo-element adds depth. Neon glow — layered text-shadow or box-shadow.
Glass Card
backdrop-filter: blur(16px)
background: rgba(255,255,255,.06)
/* ── GLASSMORPHISM ───────────────────────────── */ .glass-card { background: rgba(255, 255, 255, 0.06); backdrop-filter: blur(16px) saturate(1.8); -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(255,255,255,0.15); /* MUST have a colorful background BEHIND it */ } /* ── GRAIN TEXTURE ───────────────────────────── */ .grain::after { content: ''; position: absolute; inset: 0; background-image: url("data:image/svg+xml,..."); /* SVG feTurbulence noise filter */ background-size: 200px; pointer-events: none; opacity: 0.08; } /* ── NEON GLOW TEXT ──────────────────────────── */ .neon { color: #fff; text-shadow: 0 0 8px var(--cyan), 0 0 24px var(--cyan), 0 0 48px rgba(0,212,200,0.4); } /* ── BOX GLOW ────────────────────────────────── */ .glow-btn { box-shadow: 0 0 20px rgba(155,89,255,0.5); } .glow-btn:hover { box-shadow: 0 0 40px rgba(155,89,255,0.8); }
§ 18 — ::before + ::after + attr()
CSS-Only Tooltips
Tooltips using data-tooltip attributes and CSS attr(). The ::before pseudo-element shows the text; ::after adds the arrow. Zero JavaScript, works on any element.
§ 19 — :has() + grid-template-rows animation
Animated Accordion
The smoothest CSS accordion technique — animate grid-template-rows from 0fr to 1fr. The :has(:checked) selector watches a hidden checkbox inside each item. CSS-only and animation-smooth.
prefers-color-scheme. Dark mode reduces eye strain in low-light. If adding full dark mode, use semantic tokens so you only change variable values, not every component style. Avoid pure #000 black — dark gray (#111-#1a1a) feels more premium.translateY(-1px) on hover creates a "lift" with a subtle box-shadow change; (2) transition background-color and box-shadow separately at different speeds for naturalness; (3) add a subtle border even on solid buttons for definition; (4) use letter-spacing: .3px on uppercase labels for a typographic touch.§ 20 — More CSS-only UI patterns
Ratings & Progress Bars
A CSS-only star rating uses the reverse sibling selector trick — radio inputs laid out in reverse order, so :checked ~ label highlights the right number of stars. Progress bars use CSS animation.
Click any star — no JavaScript
flex-direction:row-reverse. So :checked ~ label flows backwards, highlighting all stars before the selected one.