Why generic CSS variables become technical debt
The classic antipattern: you start with --blue, --dark-blue, --lighter-blue. Works in MVP. Six months later you have --blue-for-buttons, --blue-but-darker, --new-blue and --blue-final-v2. Without scalable naming convention, each new variable is an ad-hoc decision the next dev will interpret differently.
Best design systems use two layers of variables: primitives and semantics. Primitives are raw values: --color-blue-500: #3b82f6. Semantics map intention: --color-primary: var(--color-blue-500). This allows changing the blue across the entire app by modifying one line. But here's the trick: primitives must follow numeric scales (50-900 Tailwind style) or t-shirt sizing (xs-xl), never --color-blue-kinda-light.
A Shopify design ops team documented that 70% of visual bugs after rebrand came from hardcoded colors instead of using semantic variables. Someone put color: #3b82f6 directly in CSS instead of color: var(--color-primary). When they changed branding, they had to grep through 847 files searching for that specific hex. Solution: linter that blocks raw values in favor of custom properties.
Spacing scales that don't break in responsive without media queries
Most common spacing mistake: defining --spacing-small: 8px, --spacing-medium: 16px, --spacing-large: 24px then being surprised it looks bad on mobile. Fixed spacing doesn't scale. Best systems use clamp() for fluid spacing: --spacing-md: clamp(1rem, 2vw, 1.5rem). Grows with viewport without media queries.
Numeric scale (4px, 8px, 12px, 16px, 24px, 32px...) is predictable but rigid. T-shirt sizing (xs, sm, md, lg, xl) is more semantic but requires consensus: is your 'md' 16px or 20px? Large teams combine both: --spacing-4 (primitive) + --spacing-md (semantic that maps to --spacing-4). This way you can refactor the scale without breaking components.
A spacing gotcha: don't use same variables for padding and gap. Flexbox/grid gap behaves differently than padding (collapses at edges). Better have --gap-sm separate from --spacing-sm, or at least clearly document which is for what. And never, NEVER define --spacing-default without number — 'default' doesn't communicate scale or predictability.
Typography tokens that survive adding new fonts
The antipattern: --font-heading, --font-body, --font-small. What happens when you want to add a display font for hero sections, or a mono for code blocks? You end up with --font-heading-v2 or --font-hero-special. Better separate family, size and weight into independent variables: --font-family-sans, --font-size-lg, --font-weight-bold. Combine in use: font: var(--font-weight-bold) var(--font-size-lg) / var(--line-height-tight) var(--font-family-sans).
For font-size, numeric scale fails to communicate intention. Is --font-size-5 large or small? Better: --font-size-sm, --font-size-base, --font-size-lg, --font-size-xl, --font-size-2xl... up to --font-size-5xl for hero headings. And add fluid variants: --font-size-fluid-lg: clamp(1.25rem, 2vw + 1rem, 2rem). Adapts itself from mobile to desktop.
Line-height is where most systems fail. Defining global --line-height: 1.5 doesn't work — headings need tighter (1.2), body text needs relaxed (1.6), buttons need 1. Better: --line-height-tight, --line-height-normal, --line-height-relaxed. And never use units in line-height (e.g. 24px) — always unitless (1.5) so it scales proportionally to font-size.
Common mistakes that break custom property inheritance
Custom properties inherit through CSS cascade, but many devs use them as if they were preprocessor variables. Common example: you define --color-text: black in :root, but then in a dark component you do hardcoded background: black; color: white. Text inside inherits --color-text: black and becomes invisible. Solution: always redefine variables in context: .dark { --color-text: white; --color-bg: black; }.
Another gotcha: custom properties don't work in media queries. You can't do @media (min-width: var(--breakpoint-md)). Variables only work in property values, not at-rules. You have to define breakpoints as separate constants or use PostCSS custom media queries. Common workaround is having --breakpoint-md as documentation but using it hardcoded in @media.
Most subtle mistake: using incorrect fallbacks. var(--color-primary, blue) seems safe, but if --color-primary is defined as invalid or a value the browser doesn't understand, the fallback is NOT used — browser uses inherited or initial value. Best practice: always define all custom variables in :root even with placeholder, never depend on var() fallback for critical values. And use @supports to detect if custom properties are available before depending on them.