04 — Color Spaces, Units & the Fluid Math of CSS
CSS has a unit system and a color system. Most bugs in both come from not knowing what the unit is relative to (rem→root, em→parent, %→parent's same-axis dimension, vw→viewport including scrollbar) and not knowing that HSL lightness is a lie (50% lightness varies in perceived brightness across hues). This chapter engineers both.
Color — the perceptually uniform truth
css
:root {
--c-primary: oklch(0.62 0.18 245); /* L=0-1, C=chroma 0-0.37, H=0-360. Perceptually uniform. */
--c-primary-hover: color-mix(in oklch, var(--c-primary) 85%, white 15%); /* derive tint at runtime */
--c-danger: oklch(0.62 0.22 25); /* same L as primary → SAME perceived brightness. HSL can't do this. */
--c-on-primary: oklch(0.98 0 0); /* near-white, zero chroma → grayscale */
}
/* Why OKLCH: hsl(60,100%,50%) yellow looks WAY brighter than hsl(240,100%,50%) blue
at the "same" 50% lightness. OKLCH's L axis is perceptually uniform — 0.62 looks
equally bright whether the hue is blue, red, or green. Deriving palettes by
tweaking L is now mathematically sound. */
Anti-pattern: HSL for derived palettes
css
/* ❌ HSL lightness is NOT perceptual. These two "50% lightness" colors look wildly different: */
--yellow: hsl(60, 100%, 50%); /* appears near-white-bright */
--blue: hsl(240, 100%, 50%); /* appears mid-dark */
/* Deriving a "lighter" shade by bumping lightness 10% gives inconsistent results per hue. */
css
/* ✓ OKLCH: same L = same perceived brightness. Tint/shade derivation is uniform. */
--base: oklch(0.62 0.18 245);
--tint: oklch(0.72 0.14 245); /* +0.10 L → consistent perceptual lightening */
--shade: oklch(0.52 0.16 245); /* -0.10 L → consistent perceptual darkening */
opacity vs rgba — what gets transparent
css
.card { opacity: 0.5; } /* ENTIRE element + ALL children → 50% transparent. Text too. */
.card { background: rgb(0 0 0 / 0.5); } /* only the background color is 50% transparent. Children opaque. */
/* Use rgb()/oklch() with alpha for semi-transparent surfaces. Use opacity only when you
mean the whole subtree (including text) should fade. opacity < 1 also creates a stacking context. */
Units — what each is relative to
text
px → CSS pixels (1/96 inch). Absolute. Ignores user font-size setting (a11y issue for text).
em → parent's font-size. COMPOUNDS: 1.2em inside 1.2em = 1.44× root. Trap for nested components.
rem → root (html) font-size. Does NOT compound. Consistent. Use for font-size and spacing.
% → parent's SAME-AXIS dimension. width:% → parent width. height:% → parent height (needs defined parent height).
vw/vh → 1% of viewport. ⚠️ 100vw INCLUDES scrollbar on desktop → horizontal overflow.
dvh/dvw → dynamic viewport (adjusts when mobile address bar shows/hides). Use instead of vh/vw.
svh → smallest viewport (all browser chrome visible). Stable, no jump.
lvh → largest viewport (all chrome hidden). = old vh behavior.
ch → width of "0" glyph in current font. Use for max-width:60ch (reading measure).
cqw/cqi → 1% of query container's inline size. Container-relative fluid type.
Anti-pattern: em for font-size in nested components
css
/* ❌ em compounds through nesting → 1.2 × 1.2 × 1.2 = 1.728× in a deeply nested structure */
.card { font-size: 1.2em; } /* 1.2× parent */
.card .body { font-size: 1.2em; } /* 1.2× .card → 1.44× root */
.card .body p { font-size: 1.2em; } /* 1.728× root — unintended escalation */
css
/* ✓ rem is root-relative → never compounds regardless of nesting depth */
.card { font-size: 1.125rem; } /* 18px (at 16px root) */
.card .body { font-size: 1rem; } /* 16px — stays at root regardless of nesting */
/* Use em ONLY for component-internal spacing that should scale with the element's own font-size. */
calc(), min(), max(), clamp() — the responsive math engine
css
:root {
/* Fluid type: clamp(min, preferred, max). The preferred formula scales with viewport.
No media queries. Bounds ensure a11y (never below 1rem = respects user setting). */
--font-body: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
--fluid-space: clamp(1rem, 2vw + 1rem, 3rem);
/* calc mixes units. ⚠️ spaces required around + and - or it's invalid. */
--sidebar-offset: calc(100% - var(--sidebar-w) - var(--gap));
--neg-calc: calc(var(--n) * 1px); /* multiply unitless var by 1px to add a unit */
}
.layout {
/* min(): picks the smaller → caps at 600px but shrinks on narrow screens */
width: min(100%, 600px);
/* max(): picks the larger → floor at 1rem, grows with viewport */
font-size: max(1rem, 2vw);
}
The fluid container pattern (no breakpoints)
css
.container {
/* fill viewport, cap at 1200px, center. The only responsive container you need. */
width: min(100% - 2rem, 1200px);
margin-inline: auto;
}
currentColor — the inheritance bridge
css
.icon { fill: currentColor; } /* SVG fill tracks the element's computed `color` → recolor via color */
.link { color: var(--c-primary); }
.link .icon { fill: currentColor; } /* icon matches link color automatically. No separate --icon-color. */
💡 Tips & Tricks
- Idiom:
oklch()+color-mix()replaces every preprocessor color function —color-mix(in oklch, var(--c) 80%, white)derives a tint at runtime, cascades, and is JS-accessible. Nolighten()mixin needed. - Idiom:
clamp(1rem, 0.5rem + 2vw, 3rem)for fluid type — the0.5rem + 2vwpreferred value gives a linear ramp; the rem-based bounds respect the user's browser font-size setting (px bounds don't). - Idiom:
dvhfor mobile full-height sections —100vhon mobile includes the address bar's potential space → content hidden behind chrome.100dvhadjusts dynamically.100svhfor the stable smallest case. - Idiom:
chfor text measure —max-width: 65chgives ~65 characters per line, the optimal reading width, regardless of font size. Better than a fixed px max-width.
⚠️ Edge Cases & Gotchas
100vwincludes the desktop scrollbar → horizontal overflow. Use100%of a full-width parent ordvw.calc()requires spaces around+/-:calc(100%-20px)is invalid (parsed as a negative percentage).calc(100% - 20px)works.*//don't need spaces.- Hex alpha is the last 2 digits:
#ff000080is 50% red (80hex = 128/255 ≈ 50%).#ff0000ffis opaque. currentColoris the computedcolor: it changes with the element'scolorproperty — it's not a frozen reference. It also creates no stacking context (unlikeopacity).%height needs a parent with a concrete height:height: 100%of aheight: autoparent resolves to 0. Use100dvh, flexalign-items: stretch, or set the parent's height.
🧠 Spot the Bug
css
.hero { height: 100vh; }
Answer
100vh on mobile browsers is the large viewport — it includes space the address bar could occupy. When the address bar is visible (the common case), the hero is taller than the visible area → the bottom is behind the address bar / cut off. Fix: 100dvh (dynamic — resizes when chrome shows/hides) or 100svh (smallest — stable, always fits).