CSS Variables and Functions

Chapter 9 — CSS Variables and Functions

CSS custom properties (commonly called CSS variables) are one of the most transformative features in modern CSS. Unlike preprocessor variables (Sass $var, Less @var), they exist at runtime in the browser, inherit through the DOM, can be read and written by JavaScript, and respond to media queries. Combined with the growing CSS function library — calc(), clamp(), min(), max(), color-mix() and more — they enable design systems that adapt without any tooling.

1. Custom Properties — Fundamentals

Declaration and use

/* Declare with -- prefix. Convention: :root for globals */ :root { --color-primary: #58a6ff; --color-surface: #161b22; --spacing-base: 1rem; --border-radius: 6px; --font-heading: 'Georgia', serif; } /* Use with var() */ .card { background: var(--color-surface); border-radius: var(--border-radius); padding: var(--spacing-base); } /* Fallback — second argument to var() */ .widget { color: var(--color-accent, #58a6ff); /* single fallback */ color: var(--color-accent, var(--color-primary, blue)); /* chained */ } /* Custom properties can hold ANY valid CSS value fragment */ :root { --shadow: 0 4px 12px rgb(0 0 0 / 0.3); --gradient: linear-gradient(to right, #58a6ff, #bc8cff); --transition: 0.2s ease; } .btn { box-shadow: var(--shadow); background: var(--gradient); transition: transform var(--transition); } /* Numbers without units — combine with calc() */ :root { --scale: 1.25; /* unitless — can't use directly in font-size */ } h1 { font-size: calc(var(--scale) * 1rem); /* multiply by unit to get length */ }
Gotcha — invalid values are not errors. If a custom property is declared but holds a value that doesn't make sense for the property it's used in (e.g. --color: hello used in color: var(--color)), the browser doesn't use the fallback — it uses the inherited value, or initial if there is none. This is different from a missing variable (which does use the fallback).

Inheritance — how custom properties flow down the DOM

Custom properties inherit like any other CSS property — a child element gets the value declared on its nearest ancestor. Redeclaring on a child overrides it for that subtree only.

:root --color-brand: #58a6ff
body inherits --color-brand: #58a6ff
.card inherits --color-brand: #58a6ff
.card__title inherits --color-brand: #58a6ff used via var()
.card--alt --color-brand: #bc8cff (redeclared!) overrides for this subtree
.card--alt .title sees --color-brand: #bc8cff inherits override
/* Component-level override — no new class needed on children */ :root { --color-brand: #58a6ff; } .card--alt { --color-brand: #bc8cff; } /* Any descendant of .card--alt now sees the purple value */ .card__title { color: var(--color-brand); } /* Prevent inheritance — set to initial on a boundary element */ .isolated { --color-brand: initial; }

2. Scoping Patterns

Global token scale

/* Design tokens on :root */ :root { --space-1: 4px; --space-2: 8px; --space-3: 16px; --space-4: 24px; --space-5: 40px; --radius-sm: 4px; --radius-md: 8px; --radius-full: 9999px; }

Component-scoped

/* Expose knobs, use tokens internally */ .btn { --btn-bg: var(--color-primary); --btn-color: #fff; --btn-radius: var(--radius-md); background: var(--btn-bg); color: var(--btn-color); border-radius: var(--btn-radius); } .btn--ghost { --btn-bg: transparent; --btn-color: var(--color-primary); }

Media-query override

/* Responsive spacing via variable swap */ :root { --page-padding: 1rem; --heading-size: 1.5rem; } @media (min-width: 768px) { :root { --page-padding: 3rem; --heading-size: 2.5rem; } } /* All elements using --page-padding now update at the breakpoint */

Dark mode theming

:root { --bg: #ffffff; --text: #1a1a1a; --surface: #f5f5f5; } @media (prefers-color-scheme: dark) { :root { --bg: #0d1117; --text: #c9d1d9; --surface: #161b22; } } /* Manual toggle overrides this */ [data-theme="dark"] { --bg: #0d1117; --text: #c9d1d9; }

Dark mode in practice — two themes, one set of rules

Light theme

Dark theme

3. JavaScript Interop

/* Read a custom property value */ const root = document.documentElement; const styles = getComputedStyle(root); const primary = styles.getPropertyValue('--color-primary').trim(); // '--color-primary' → '#58a6ff' /* Write / override a custom property */ root.style.setProperty('--color-primary', '#ff7b72'); /* Remove an override (reverts to stylesheet value) */ root.style.removeProperty('--color-primary'); /* Theme toggle pattern */ const toggle = document.querySelector('#theme-btn'); toggle.addEventListener('click', () => { const current = root.dataset.theme; root.dataset.theme = current === 'dark' ? 'light' : 'dark'; localStorage.setItem('theme', root.dataset.theme); }); /* Animate a CSS variable with JS (pointer-tracked glow effect) */ document.addEventListener('mousemove', (e) => { root.style.setProperty('--mouse-x', e.clientX + 'px'); root.style.setProperty('--mouse-y', e.clientY + 'px'); }); /* CSS picks it up: background: radial-gradient(at var(--mouse-x) var(--mouse-y), ...) */
Performance tip. Setting a custom property on a DOM element triggers a style recalculation for that element and its descendants. Setting it on :root via document.documentElement.style.setProperty() triggers a global recalc. For high-frequency updates (mouse tracking, scroll), scope the variable to a small subtree or use requestAnimationFrame to batch updates.

4. @property — Typed Custom Properties

By default, CSS custom properties are untyped strings. The browser doesn't know if --angle is a number, an angle, or a colour — it just substitutes the text. This means you cannot transition or animate them. @property registers a custom property with a type, initial value, and inheritance flag — unlocking animation, validation, and IDE autocomplete.

/* @property syntax */ @property --hue { syntax: '<number>'; /* the CSS type */ inherits: false; /* does not flow to children */ initial-value: 220; /* required when inherits: false */ } /* Now --hue can be animated! */ .rainbow-text { --hue: 0; color: hsl(var(--hue) 80% 60%); animation: cycle-hue 3s linear infinite; } @keyframes cycle-hue { to { --hue: 360; } } /* Without @property this would snap — the browser wouldn't know --hue is a number */

Available syntax types

<number>
Any real number. Can be animated between two values. Use for counters, ratios, multipliers.
--scale: 1.25; --opacity-level: 0.8;
<integer>
Whole numbers only. Snaps during animation — no interpolation between steps.
--z-index-level: 10; --columns: 3;
<length>
Any CSS length value (px, rem, em, vw…). Can be animated. Use for spacing, offsets, sizes.
--offset: 24px; --hero-height: 60vh;
<percentage>
A % value. Animatable. Often combined with <length-percentage> to accept both.
--fill: 75%; --progress: 0%;
<color>
Any valid colour. Can be animated — the browser interpolates between colours. Use for theme tokens you want to transition.
--brand: oklch(70% 0.2 250); --surface: #161b22;
<angle>
deg, rad, turn, grad. Can be animated. Use for gradient rotation, conic effects, transform rotation via custom property.
--rotation: 0deg; --gradient-angle: 135deg;
<image>
url(), gradient functions. Cannot be animated but can be swapped. Use for swappable background tokens.
--hero-bg: url('/hero.webp'); --pattern: repeating-linear-gradient(...);
* (universal)
Accepts any value — behaves like an unregistered custom property. Still prevents animation but adds initial-value and inheritance control.
/* Same as unregistered but with defaults */

5. The CSS Function Library

Math functions — calc(), min(), max(), clamp()

width: calc(100% - 2rem)
full width minus 2rem padding
width: min(100%, 600px)
smaller of 100% or 600px
width: max(200px, 50%)
larger of 200px or 50%
font-size: clamp(1rem, 4vw, 2rem)
min 1rem, preferred 4vw, max 2rem
/* calc() — mix any units, all four operators */ .sidebar { width: calc(300px - 2 * var(--gap)); /* variables inside calc */ margin: calc(var(--spacing) * 1.5); /* multiply unitless var by unit */ } /* Note: + and - operators MUST have spaces around them */ /* calc(100% -1rem) is invalid. calc(100% - 1rem) is valid. */ /* min() — use for intrinsic fluid container widths */ .container { width: min(100% - 2rem, 1200px); /* fluid up to 1200px, always 1rem margin */ } /* max() — enforce a minimum */ .touch-target { min-height: max(44px, 10vh); /* at least 44px (WCAG), or 10vh if larger */ } /* clamp(MIN, PREFERRED, MAX) */ :root { --step-0: clamp(1rem, calc(0.875rem + 0.5vw), 1.125rem); --step-1: clamp(1.25rem, calc(1rem + 1vw), 1.75rem); --step-2: clamp(1.5rem, calc(1rem + 2vw), 2.5rem); --step-3: clamp(2rem, calc(1rem + 3.5vw), 3.5rem); } /* Fluid type scale — no media queries at all */

clamp() visualised

clamp(1rem, 4vw, 2.5rem) — how font-size responds to viewport width
Narrow viewport
locked at 1rem (MIN)
Mid viewport
scales with 4vw (PREFERRED)
Wide viewport
locked at 2.5rem (MAX)

Full function reference

FunctionCategoryDescription + Example
calc() Math Arithmetic with mixed units. Supports +, -, *, /. Spaces required around + and -.
calc(100% - var(--sidebar-w) - 2rem)
min() Math Returns the smallest argument. Accepts any number of comma-separated values.
width: min(100% - 2rem, 960px)
max() Math Returns the largest argument. Useful for enforcing minimum values.
padding: max(1rem, 5vw)
clamp() Math Equivalent to max(MIN, min(PREFERRED, MAX)). Clamps a fluid value between two bounds.
font-size: clamp(1rem, 2.5vw, 1.75rem)
round() Math Rounds a value to the nearest multiple. Args: strategy (nearest/up/down/to-zero), value, interval.
width: round(nearest, 53.7px, 5px) → 55px
mod() / rem() Math Modulo operations. mod() matches divisor sign; rem() matches dividend sign.
mod(18, 5) → 3
abs() / sign() Math abs() returns magnitude; sign() returns -1, 0, or 1. Useful with custom properties for mirroring.
margin-left: abs(var(--offset))
sin() cos() tan() asin() acos() atan() atan2() Trig Trigonometric functions. Input in radians or angle values. Used for circular layouts and wave effects.
--x: calc(cos(var(--angle)) * var(--radius))
pow() sqrt() hypot() log() exp() Math Exponential functions. pow(base, exponent), sqrt(), hypot() = √(a²+b²). Useful for modular scale generation.
--step-2: calc(pow(1.25, 2) * 1rem)
var() Variable Reads a custom property. Optional fallback as second argument.
color: var(--text-color, #c9d1d9)
env() Environment Reads environment variables set by the browser. Most commonly used for iOS safe area insets.
padding-bottom: env(safe-area-inset-bottom)
attr() DOM Reads an HTML attribute value for use in CSS content. Currently limited to content: in pseudo-elements; typed attr() with units is an emerging spec.
content: attr(data-label)
color-mix() Color Mixes two colours in a specified colour space. Percentage controls the mix ratio.
color-mix(in oklch, #58a6ff 70%, #bc8cff)
color-contrast() Color Selects from a list the colour with highest contrast against a base. (Emerging — limited support.)
color: color-contrast(#161b22 vs white, black)
light-dark() Color Returns the first value in light mode, second in dark mode. Requires color-scheme to be set.
color: light-dark(#1a1a1a, #c9d1d9)
oklch() oklab() lch() lab() Color Perceptual colour space functions. oklch(lightness chroma hue) for design-system tokens.
--brand: oklch(60% 0.22 250)
hsl() hwb() rgb() Color Classic colour functions. Modern syntax drops commas and uses / for alpha.
hsl(220 80% 60% / 0.8)
url() Reference References an external resource — image, font, SVG.
background: url('/images/bg.webp')
format() local() Font Used inside @font-face src. format() hints the file type; local() checks for installed fonts.
src: local('Inter'), url('inter.woff2') format('woff2')
linear() cubic-bezier() steps() Easing Custom easing functions for transitions and animations. Covered in Chapter 8.
transition-timing-function: cubic-bezier(0.34,1.56,0.64,1)

6. Full Design Token System

/* ── Layer 1: Primitive tokens (raw values, no semantic meaning) */ :root { /* Color palette */ --blue-50: oklch(96% 0.03 250); --blue-500: oklch(60% 0.22 250); --blue-900: oklch(20% 0.10 250); /* Spacing scale */ --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem; --space-4: 1rem; --space-6: 1.5rem; --space-8: 2rem; /* Type scale */ --text-sm: clamp(0.8rem, 1.5vw, 0.875rem); --text-base: clamp(1rem, 2vw, 1.125rem); --text-lg: clamp(1.25rem, 2.5vw, 1.5rem); --text-xl: clamp(1.5rem, 3.5vw, 2.25rem); --text-2xl: clamp(2rem, 5vw, 3.5rem); } /* ── Layer 2: Semantic tokens (reference primitives, carry meaning) */ :root { --color-bg: var(--blue-50); --color-text: var(--blue-900); --color-accent: var(--blue-500); --gap-sm: var(--space-2); --gap-md: var(--space-4); --gap-lg: var(--space-8); } /* ── Layer 3: Dark mode swaps semantic tokens only */ @media (prefers-color-scheme: dark) { :root { --color-bg: var(--blue-900); --color-text: var(--blue-50); /* --color-accent stays the same */ } } /* ── Component uses semantic tokens — works in both modes */ body { background: var(--color-bg); color: var(--color-text); } .link { color: var(--color-accent); }
Three-layer token architecture: Primitives (the raw palette) → Semantic tokens (what things mean — background, text, accent) → Component tokens (local knobs that reference semantics). Only swap semantic tokens for theming. Components never reference primitives directly — that decoupling is what makes theme switching trivial.

Chapter Summary

ConceptKey point
var() fallbackSecond argument is the fallback — used only when the property is not set, not when it holds an invalid value. Invalid values fall back to inherited or initial.
Inheritance scopeCustom properties inherit like any CSS property. Redeclaring on a child overrides for that entire subtree — no new selectors needed on descendants.
Unitless numbersStore unitless numbers (--scale: 1.25) and multiply by a unit inside calc(). Allows JS to update the number cleanly without knowing the unit.
@propertyRegisters a custom property with a type, initial-value, and inherits flag. Required to animate a custom property. Must declare syntax and initial-value when inherits: false.
calc() gotchaSpaces are mandatory around + and -. calc(100%+2rem) fails silently; calc(100% + 2rem) works. Multiplication and division don't need spaces.
min() for containersmin(100% - 2rem, 1200px) is the cleanest fluid container — no media query, always has 1rem margin on small screens, caps at 1200px on wide.
clamp() for typeclamp(MIN, preferred vw expression, MAX) creates fluid typography. The preferred value should use viewport units so it scales; min and max cap the extremes.
color-mix()color-mix(in oklch, colorA 70%, colorB) mixes two colours. oklch gives perceptually uniform mixing — in hsl midpoints often go grey or muddy.
light-dark()light-dark(light-value, dark-value) is a shorthand for two-value colour-scheme switching. Requires color-scheme: light dark on :root.
env()Reads browser environment variables. safe-area-inset-* is essential for PWAs and mobile web apps on notched devices.
Three-layer tokensPrimitives → Semantic → Component. Only swap semantics for theming. Components reference semantics, never primitives directly.
Exercises
  1. Fluid type scale: Define five type scale steps on :root using clamp(). Apply them to h1–h4 and body. Open DevTools device toolbar and drag the viewport width between 320px and 1400px — confirm the text scales smoothly with no jumps.
  2. Component token pattern: Build a .badge component that exposes --badge-bg, --badge-color, and --badge-radius. Create three variants (.badge--success, .badge--warning, .badge--error) that only override the component-level tokens — no duplicating the structural rules.
  3. Dark mode with data-theme: Set up a light/dark theme using semantic tokens (--bg, --text, --surface, --accent). Implement both @media (prefers-color-scheme: dark) and a manual [data-theme="dark"] override. Add a button that toggles the attribute and persists the choice to localStorage.
  4. @property animation: Register --progress as a <percentage> with initial-value: 0%. Build a progress bar that uses width: var(--progress). Animate it from 0% to 100% over 3 seconds. Confirm it interpolates smoothly (it will fail without @property — test both ways).
  5. color-mix() palette generator: Define a single brand colour as an oklch value. Generate tints and shades using color-mix(in oklch, var(--brand) N%, white) and color-mix(in oklch, var(--brand) N%, black). Build a swatch row showing the full scale from 10% brand to 90% brand.
Next: Chapter 10 — Modern CSS Features. Cascade layers (@layer), the @scope rule, nesting, :has() in layout contexts, the new logical properties system, content-visibility, and what's shipping in 2025–2026.