CSS custom properties (variables) have matured from a curiosity into the backbone of design systems. They enable live theming, scoped tokens, and JavaScript–CSS bridges that were previously impossible without a preprocessor. This post covers patterns worth knowing.
The Basics Custom properties are declared with a -- prefix and accessed via var():
1 2 3 4 5 6 7 8 9 10 11 :root { --color-accent : #89b4fa ; --spacing-md : 1rem ; --radius : 6px ; }.button { background : var (--color-accent); padding : var (--spacing-md); border-radius : var (--radius); }
Fallback Values var() accepts an optional fallback — useful for progressive enhancement:
1 2 3 4 .card { color : var (--color-text, #cdd6f4 ); border : 1px solid var (--color-border, rgba (255 ,255 ,255 ,0.1 )); }
The fallback can itself reference another custom property:
1 2 3 .element { color : var (--color-primary, var (--color-accent, royalblue)); }
Scoped Properties Unlike preprocessor variables, custom properties respect the cascade and can be scoped to a subtree:
1 2 3 4 5 6 7 8 9 10 11 :root { --btn-bg : var (--color-surface); --btn-text : var (--color-text); }.hero .button { --btn-bg : var (--color-accent); --btn-text : var (--color-bg); }
This pattern lets you theme variants without adding extra classes:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 .alert { --alert-color : var (--color-text); --alert-bg : var (--color-surface); border-left : 3px solid var (--alert-color); background : var (--alert-bg); }.alert--warning { --alert-color : #f9e2af ; --alert-bg : rgba (249 , 226 , 175 , 0.08 ); }.alert--error { --alert-color : #f38ba8 ; --alert-bg : rgba (243 , 139 , 168 , 0.08 ); }
Dark Mode Theming Custom properties shine for dark/light theming. Define two token sets and switch them with prefers-color-scheme or a class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 :root { --color-bg : #ffffff ; --color-text : #1a1a2e ; --color-surface : #f5f5f5 ; --color-border : #e0e0e0 ; }@media (prefers-color-scheme : dark) { :root { --color-bg : #1e1e2e ; --color-text : #cdd6f4 ; --color-surface : #252535 ; --color-border : #3a3a5c ; } }:root .dark { --color-bg : #1e1e2e ; --color-text : #cdd6f4 ; --color-surface : #252535 ; --color-border : #3a3a5c ; }
Computed Values and Calc Custom properties store raw tokens and can participate in calc():
1 2 3 4 5 6 7 8 9 10 11 12 :root { --base-size : 4 ; --sidebar-width : 240 ; }.sidebar { width : calc (var (--sidebar-width) * 1px ); }.spacing-2 { padding : calc (var (--base-size) * 2px ); }.spacing-4 { padding : calc (var (--base-size) * 4px ); }.spacing-8 { padding : calc (var (--base-size) * 8px ); }
JavaScript Bridge Custom properties are readable and writable via JavaScript — the only way to expose CSS values to JS without hard-coding them in two places:
1 2 3 4 5 6 7 8 9 10 11 12 const accent = getComputedStyle (document .documentElement ) .getPropertyValue ('--color-accent' ) .trim ();document .documentElement .style .setProperty ('--sidebar-width' , '320px' );function setTheme (theme ) { document .documentElement .setAttribute ('data-theme' , theme); }
1 2 3 4 5 6 7 8 9 :root [data-theme="catppuccin" ] { --color-accent : #cba6f7 ; --color-bg : #1e1e2e ; }:root [data-theme="nord" ] { --color-accent : #88c0d0 ; --color-bg : #2e3440 ; }
Animation with Custom Properties Individual custom properties can be registered for animation with @property:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @property --progress { syntax: '<number>' ; initial-value: 0 ; inherits: false; }.progress-ring { --progress : 0 ; stroke-dashoffset : calc (339 - (339 * var (--progress)) / 100 ); transition : --progress 600ms ease; }.progress-ring .loaded { --progress : 75 ; }
Without @property, browsers can’t interpolate custom properties because they’re treated as opaque strings.
Design Token Naming Convention A consistent naming scheme scales well across a design system:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 :root { --blue-400 : #60a5fa ; --blue-500 : #3b82f6 ; --blue-600 : #2563eb ; --gray-900 : #111827 ; --gray-800 : #1f2937 ; --color-bg : var (--gray-900 ); --color-surface : var (--gray-800 ); --color-text : #f9fafb ; --color-accent : var (--blue-500 ); --color-accent-hover : var (--blue-400 ); --color-accent-pressed : var (--blue-600 ); }
Semantic tokens are what components reference. Primitives can be swapped independently.
What Custom Properties Can’t Do (Yet)
They cannot be used in media query expressions: @media (max-width: var(--breakpoint-md)) doesn’t work
Nesting inside url() — background: url(var(--img-path)) is invalid
They inherit by default, which occasionally causes unexpected cascading
The cascade and inheritance issues are the most common footguns. When in doubt, scope them tightly with :where() or a specific selector.