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. No lighten() mixin needed.
  • Idiom: clamp(1rem, 0.5rem + 2vw, 3rem) for fluid type — the 0.5rem + 2vw preferred value gives a linear ramp; the rem-based bounds respect the user's browser font-size setting (px bounds don't).
  • Idiom: dvh for mobile full-height sections — 100vh on mobile includes the address bar's potential space → content hidden behind chrome. 100dvh adjusts dynamically. 100svh for the stable smallest case.
  • Idiom: ch for text measure — max-width: 65ch gives ~65 characters per line, the optimal reading width, regardless of font size. Better than a fixed px max-width.

⚠️ Edge Cases & Gotchas

  • 100vw includes the desktop scrollbar → horizontal overflow. Use 100% of a full-width parent or dvw.
  • 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: #ff000080 is 50% red (80 hex = 128/255 ≈ 50%). #ff0000ff is opaque.
  • currentColor is the computed color: it changes with the element's color property — it's not a frozen reference. It also creates no stacking context (unlike opacity).
  • % height needs a parent with a concrete height: height: 100% of a height: auto parent resolves to 0. Use 100dvh, flex align-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).