Door 11 – CSS Custom Properties | CSS Adventskalender
Skip to content

Door 11 – CSS Custom Properties

Published: at 07:00 AM

CSS Custom Properties – often simply called “CSS variables” – are among the most important tools of modern CSS architecture. Unlike preprocessor variables (Sass, Less), they are evaluated at runtime, can be inherited, overridden, and changed context-dependently.

That is exactly what makes them so powerful. They are not just an aid for colors, but a central building block for design systems, themeable interfaces, fluid design, and component-based architectures.

This door explains how Custom Properties work, what pitfalls exist, and how to use them professionally.

The Difference to Sass Variables

Many developers have been using Sass for years and initially believe CSS Custom Properties are simply “the same in pure CSS”, just without a preprocessor. This assumption is fundamentally wrong and often leads to missing the full potential of Custom Properties.

The crucial difference lies in the timing of resolution. Sass variables only exist during the build process, while CSS Custom Properties live and work at runtime in the browser. This initially sounds like a technical detail but has far-reaching consequences for the entire architecture.

Sass Variables – Static at Build Time

Let’s look at a classic Sass example:

$primary: #0d6efd;

.button {
  background: $primary;
}

When Sass compiles this code, the variable $primary is replaced with its value. The resulting CSS no longer contains a variable, but the direct value. The browser later only sees background: #0d6efd. The variable itself no longer exists in the final product.

This build-time resolution means that Sass variables are completely static. Once the CSS is compiled, everything is set in stone. You cannot change the color without running the entire build process again. Components cannot override the variable because there is no variable to override at runtime. User settings like Dark Mode cannot react to it because the value is already hard-wired. JavaScript cannot adjust the variable because it doesn’t exist in the browser.

These limitations are not trivial. They mean that for every variant of a theme, for every different state, for every context, you must write and compile separate CSS rules. All variability must be anticipated and prepared at build time.

CSS Custom Properties – Dynamic at Runtime

CSS Custom Properties work fundamentally differently:

:root {
  --primary: #0d6efd;
}

.button {
  background: var(--primary);
}

This code is not resolved or replaced. The variable --primary and the var() call remain in the final CSS and are evaluated by the browser at runtime. When the browser styles the element, it looks up what value --primary has at that moment in that context and uses it.

This runtime nature opens completely new possibilities. The variable can be overridden at any point in the DOM tree. A component can redefine the variable in its own scope, and all children automatically adopt the new value. This all happens in the browser, without any build step.

CSS Custom Properties are inheritable like normal CSS properties. They follow the cascade. They can be dynamically changed by JavaScript by simply calling element.style.setProperty('--primary', '#ff0000'). They react immediately to context changes like adding a CSS class or a data attribute.

A user can switch between Light and Dark Mode at runtime, and all variables adjust immediately without loading new CSS. A component can use different values for the same variable in different contexts. A dashboard can dynamically adjust its color scheme based on user preferences loaded from a database.

This runtime nature is the decisive advantage and the reason why CSS Custom Properties are not simply “Sass variables in native CSS” but represent a completely new, more powerful paradigm.

Inheritance – The Most Powerful Feature

CSS Custom Properties behave like normal CSS properties: They inherit down the DOM tree.

Example:

:root {
  --text-color: #333;
}

.article {
  color: var(--text-color);
}

If something is overridden in the article context:

.article.highlight {
  --text-color: #0055aa;
}

All children automatically adopt the new color without any further rules. This isn’t magic, but simply normal CSS inheritance as we know it from properties like color or font-family.

This inheritance mechanism makes Custom Properties the ideal tool for numerous architectural patterns. Color systems benefit enormously because you can define a color token at the root and use it in all components, while individual components or areas can override the color for their scope. A highlight section can define all primary colors differently without individually adjusting every component within it.

Spacing systems become consistent and flexible through Custom Properties. You define a set of spacing tokens like --space-xs, --space-s, --space-m centrally, and every component uses them. If a certain section should be more compact, it simply overrides the spacing variables, and all nested components automatically adapt.

Typographic scales work on the same principle. Base font sizes are defined in the root, but a sidebar can scale down the scale while a hero area scales it up. All headings, texts, and UI elements in these areas automatically adopt the adjusted sizes.

Component variants become significantly simpler. Instead of writing separate CSS classes for button--primary, button--secondary, button--danger that each redefine all properties, you can have a single .button component that uses variables. The variants then only override the variables, not the entire styling logic.

Theming becomes almost trivial with this inheritance. You define all theme tokens in the root or a [data-theme] selector, and the entire application automatically adapts. No cascading conflicts, no specificity battles, no dozens of override rules. Just variables that are inherited and overridden.

Practical Example: Theming a Component

Base Values:

:root {
  --card-bg: #ffffff;
  --card-border: #e5e5e5;
}

Override Component-Specifically:

.card--dark {
  --card-bg: #1c1c1c;
  --card-border: #333333;
}

Usage:

.card {
  background: var(--card-bg);
  border: 1px solid var(--card-border);
}

The card reacts to its own context, without additional classes on the container.

Fallbacks – Essential for Robust CSS

One of the often overlooked but critical features of Custom Properties are fallback values. The var() function can contain a second parameter that serves as a fallback:

color: var(--headline-color, #111);

This fallback is used when the variable --headline-color is not defined or contains an invalid value. This initially sounds like a nice-to-have feature but is essential for robust, production-ready CSS.

The problem is subtle but real: CSS variables can be empty or contain invalid values without an obvious error occurring. Unlike with static values where a typo like color: blu is simply ignored and the browser keeps the previous value, an empty or invalid variable causes the property to fail completely.

A practical case I see again and again in code reviews:

:root {
  --spacing-l: ;
}

This variable was defined but the value is missing or was accidentally deleted. Every component that uses this variable completely loses its spacing definition:

.section {
  margin-top: var(--spacing-l);
}

The section would suddenly have no margin-top at all. Not a value of 0, not a default value, but no value. The property falls away completely as if you had never defined it. This can lead to subtle layout breaks that are difficult to debug.

With a fallback, the problem is solved:

margin-top: var(--spacing-l, 2rem);

Now the section always has a reasonable margin, even if the variable is unavailable for some reason. The fallback is a kind of safety net.

Fallbacks are particularly important in component libraries and design systems used in different projects. You can never guarantee that all necessary variables are always correctly defined. With fallbacks, components are defensively programmed and work even in suboptimal environments.

Another use case for fallbacks are nested variable systems. You can use a variable as a fallback for another variable:

background: var(--component-bg, var(--section-bg, var(--page-bg, #ffffff)));

The system first checks if the component defines its own background color. If not, it uses the section background. If that’s not defined either, the page background. And if nothing is defined at all, it falls back to white. This cascade of fallbacks enables very flexible and robust theming systems.

Fallbacks belong in every production-ready deployment of Custom Properties. They are not optional but an essential part of defensive CSS programming.

Using Custom Properties for Layouts

The most common use of Custom Properties is for colors, but their true potential becomes apparent when you use them for layout values. Here they unfold a flexibility that is simply not achievable with static values.

Let’s consider a classic layout problem: A sidebar whose width must be controlled dynamically. With traditional CSS, you would either have to prepare multiple CSS classes for different widths or manipulate inline styles directly with JavaScript. Both approaches are suboptimal.

With Custom Properties, it becomes elegant:

:root {
  --sidebar-width: 280px;
}

.sidebar {
  width: var(--sidebar-width);
}

The sidebar has a default width of 280 pixels. This is the standard for the entire application. But now comes the magic: This width can be changed from outside without touching the component itself.

A user could have a setting in the app: “Wide Sidebar” or “Narrow Sidebar”. With JavaScript, you simply adjust the variable:

document.documentElement.style.setProperty('--sidebar-width', '350px');

The entire application reacts immediately. Not just the sidebar itself, but also all layouts that refer to the sidebar width automatically adapt. A grid layout working with grid-template-columns: var(--sidebar-width) 1fr adapts instantly. A media query referring to the sidebar width works dynamically.

This is a completely different architectural level than hardcoded layout values. The layout logic is decoupled from the concrete values. You can have different layout modes without different CSS files or complex override logic. The application becomes configurable without having to prepare dozens of variants.

Another example: Column widths in a dashboard. Different users prefer different layouts. Some want wide columns for content, others narrow columns for more overview. With Custom Properties, users can set their preferred widths, these are saved in localStorage or a database, and on the next visit, the layout is restored exactly as it was.

This works not only for widths but for all numerical layout values. Spacing, heights, border-radii, shadow strengths, transition durations – everything can be defined as Custom Properties and dynamically adjusted. This makes interfaces not only more flexible but also more accessible because users can adapt the presentation to their needs.

Custom Properties + Responsive Design

The combination of Custom Properties with modern responsive techniques like clamp(), which we learned about on Day 10, opens fascinating possibilities. You can centrally define fluid, responsive values and use them consistently everywhere.

An elegant pattern combines viewport units and clamp() in Custom Properties:

:root {
  --space-m: clamp(1rem, 1vw + 0.5rem, 2rem);
}

.section {
  padding: var(--space-m);
}

This definition is powerful in its simplicity. The medium spacing --space-m is not static but grows fluidly with the viewport. On narrow displays it’s 1rem, on wide ones up to 2rem. The formula 1vw + 0.5rem ensures continuous, proportional growth.

The decisive advantage is centralization. This single value is used in dozens or hundreds of places in the application. Every section, every card, every grid gap can use var(--space-m). If you later decide that the scale should grow differently, you only change the definition in :root. All usages adapt immediately.

This is a fundamental difference from traditional responsive CSS, where you would have to write media queries at each point of use. Instead of maintaining hundreds of media queries, you have one central, fluid definition. Complexity is concentrated at one point, and the rest of the code remains simple and readable.

You can define entire typography scales this way:

:root {
  --font-size-base: clamp(1rem, 0.5vw + 0.85rem, 1.125rem);
  --font-size-lg: clamp(1.25rem, 1vw + 1rem, 1.5rem);
  --font-size-xl: clamp(1.5rem, 1.5vw + 1rem, 2rem);
  --font-size-2xl: clamp(2rem, 2vw + 1.5rem, 3rem);
}

Every component uses these variables. The entire typographic system scales harmoniously across all viewport sizes without a single media query in the components themselves.

The same principle works for spacing scales, icon sizes, border-radii, and all other numerical values. You define a set of fluid base tokens, and the entire application automatically becomes responsive. Maintainability and consistency increase dramatically.

Custom Properties + Container Queries

The combination is particularly powerful:

.card {
  container-type: inline-size;
  --card-padding: 1rem;
  padding: var(--card-padding);
}

@container (min-width: 500px) {
  .card {
    --card-padding: 2rem;
  }
}

The component overrides only one variable, not the entire CSS property. This is a subtle but extremely important difference from traditional override logic.

With classic CSS, you would have to rewrite the complete padding property within the Container Query. If the card later receives additional padding-related changes, you would have to touch every override point. With Custom Properties, you only change the variable. The usage of padding: var(--card-padding) remains unchanged, regardless of how many different contexts require different padding values.

This leads to significantly fewer override rules in the entire codebase. Instead of having dozens of selectors that redefine the same property with different values, you have one selector for basic usage and multiple contexts that only override the variable. The CSS remains lean and the override intention is immediately recognizable.

The architecture becomes cleaner and more maintainable. Responsibilities are clearly separated. The component defines how properties are used. The contexts define what values these properties should have. This separation of concerns is exactly what makes large codebases maintainable.

Component management becomes simpler because you can deploy components in different contexts without touching their actual code. A card component works just as well in main content as in a sidebar, in a modal, or in a grid. Each context only defines the variables that make sense for it, and the component automatically adapts. This is true component autonomy and reusability.

Common Mistakes When Using Custom Properties

Despite their simplicity, Custom Properties are often not used optimally. I see the following mistakes again and again in code reviews and real projects. Avoiding them makes the difference between a functioning and a truly professional system.

Mistake 1: Defining Variables Too Deep and Too Locally

A common beginner mistake is defining variables where they are used:

.component {
  --primary: #0070f3;
}

This initially seems logical – the variable is close to its use. But it creates massive problems. First, the variable is only available within .component and its children. Other components cannot use it. You would have to duplicate the color or write complex selectors.

Second, the system becomes inconsistent. If ten different components all define their own --primary, you can no longer guarantee that the same color is used everywhere. Someone could accidentally use a slightly different hex value, and suddenly you have an inconsistent design.

The better solution is to centrally define global design tokens:

:root {
  --primary: #0070f3;
}

Now the color is available everywhere. All components are guaranteed to use the same value. When you change the primary color, it changes everywhere simultaneously. This is true consistency.

The rule is simple: The more fundamental and reusable a value is, the higher it should be defined. Global design tokens belong in :root. Component-specific variables can be defined in the component itself, but they should build on global tokens, not be completely standalone.

Mistake 2: Using Variables Only for Colors and Ignoring Their Full Potential

Many developers see Custom Properties as “CSS color variables” and use them almost exclusively for hex codes. This is one of the biggest missed opportunities in modern CSS. The limits of many design systems arise directly from this restriction.

Custom Properties can contain any CSS value. Not just colors, but absolutely everything you can write in CSS. And precisely this universality makes them so powerful.

Let’s consider a more complete token system:

--button-radius: 6px;
--button-padding: 0.5rem 1rem;
--transition-fast: 150ms ease-out;
--shadow-soft: 0 2px 8px rgba(0,0,0,0.08);

A button radius as a variable means you can switch the entire interface from rounded to square buttons with one change. No find-and-replace through hundreds of files, no risk of missing spots. Just change one definition in :root.

Padding as a variable enables consistent button sizes. You can have different button sizes (compact, default, large) that all just override the padding variable. The rest of the button logic remains identical.

Transitions as a variable are particularly valuable for animation consistency. All buttons, links, dropdowns, modals – they can all use the same transition timing. This makes the interface feel coherent. If you later decide animations should be faster or slower, you change one value.

Shadows as a variable enable elevation systems like in Material Design. You define different shadow strengths (--shadow-1, --shadow-2, --shadow-3), and components use the appropriate elevation for their purpose. A hover state can intensify the shadow by just changing the variable.

CSS Custom Properties are design tokens in the truest sense of the word. Tokens are semantic, reusable units of design. They represent decisions, not values. --transition-fast says “this animation is fast”, not “150ms”. If you later decide that “fast” means 100ms, you only change the definition.

This token mindset is fundamental for professional design systems. It decouples the “what” from the “how”. Components say “I need a soft shadow”, not “I need 0 2px 8px rgba(0,0,0,0.08)”. This makes systems maintainable, adaptable, and scalable.

Mistake 3: Chaotic Naming Without a Consistent System

Another common mistake is naming variables arbitrarily without structure or systematization:

--padding: 1rem;
--spacing: 1.5rem;
--gap: 2rem;

This system is arbitrary and quickly becomes unmanageable. What’s the difference between padding, spacing, and gap? Why do they have different values? When you need a new spacing, what do you name it? --margin? --distance? --space? The system collapses as soon as you go beyond a few variables.

The solution is a consistent scale with systematic naming:

--size-1: 0.5rem;
--size-2: 1rem;
--size-3: 1.5rem;
--size-4: 2rem;

Now it’s immediately clear what’s happening. There’s a size scale from small to large. --size-1 is the smallest value, --size-4 the largest. When you need a new value, you add --size-5. No confusion, no overlaps, no inconsistencies.

This numerical scale makes relationships between values explicit. --size-4 is twice as large as --size-2. This is not only mathematically clear but also visually predictable. Designers and developers speak the same language.

You can have different scales for different purposes:

/* Spacing Scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2rem;

/* Font Size Scale */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;

Each scale has its own prefix (space-, text-) so it’s clear what it’s meant for. Naming is consistent and predictable. You can extend the scale without breaking the system.

Uniform scales are not just nice-to-have, they are essential for maintainability of large codebases. They reduce cognitive load, make onboarding new developers easier, and prevent the creeping fragmentation that tears many design systems apart over time.

A Real Example: Light/Dark Mode with Minimal Complexity

Light and Dark Mode are now standard in modern applications. Traditionally, this required either loading different stylesheets or complex override cascades. With Custom Properties, it becomes almost trivial.

The entire theme can be implemented with minimal code:

:root {
  --bg: #ffffff;
  --text: #111111;
}

[data-theme="dark"] {
  --bg: #111111;
  --text: #ffffff;
}

body {
  background: var(--bg);
  color: var(--text);
}

This is not a simplification for the example – this is production-ready code. The entire application can switch between Light and Dark Mode by simply changing the data-theme attribute on the HTML element.

The elegance lies in inheritance. Variables are defined in :root for Light Mode. The [data-theme="dark"] selector overrides them. Since Custom Properties are inherited, all elements in the entire DOM automatically have access to the correct values. You don’t need to style each element individually, don’t need to manage dozens of classes, don’t need to write complex selectors.

The theme switch happens at runtime with a single JavaScript line:

document.documentElement.setAttribute('data-theme', 'dark');

All elements immediately adapt. No new stylesheets need to be loaded, no reflows are triggered, no visual glitches occur. The interface switches seamlessly from Light to Dark.

In reality, the system becomes more complex of course. You don’t just have two colors but an entire color system:

:root {
  --color-bg-primary: #ffffff;
  --color-bg-secondary: #f5f5f5;
  --color-text-primary: #111111;
  --color-text-secondary: #666666;
  --color-border: #e5e5e5;
  --color-accent: #0070f3;
}

[data-theme="dark"] {
  --color-bg-primary: #111111;
  --color-bg-secondary: #1a1a1a;
  --color-text-primary: #ffffff;
  --color-text-secondary: #999999;
  --color-border: #333333;
  --color-accent: #3291ff;
}

But the principle remains the same. You define all color tokens for Light Mode, override them for Dark Mode, and the entire application automatically adapts. No component needs to contain special Dark Mode code. They simply use the variables, and the variables have different values depending on the theme.

This makes theming not only easier to implement but also significantly easier to extend. If you want to add a third theme later – perhaps a High Contrast theme for accessibility – you simply add another selector. The components remain completely unchanged.

Conclusion

CSS Custom Properties are far more than just “variables in CSS”. They are a fundamental architectural tool that changes the way we build modern interfaces.

Their runtime nature makes them dynamic. Unlike preprocessor variables that disappear at build time, Custom Properties live in the browser and can be changed at runtime. This enables theming, user preferences, context-dependent styling, and dynamic adjustments that would be impossible with static values.

Their inheritance makes them powerful. They follow the CSS cascade like normal properties. A value defined in the root is available everywhere. A value overridden in a container applies to all children. This inheritance eliminates redundancy and makes overrides elegant and predictable.

Their universality makes them flexible. They are not limited to colors but can contain any CSS value. Spacing, transitions, shadows, font sizes, border-radii – everything can be tokenized and centrally managed. This makes design systems consistent and maintainable.

The combination with modern CSS features like clamp() from Day 10 and Container Queries from Day 9 makes them indispensable. You can centrally define fluid, responsive values. You can build components that override their own variables depending on available space. These are architectural patterns that were simply not possible before Custom Properties.

Custom Properties make design systems consistent because all components use the same tokens. They make components independent because each component can define and override its own variables. They make theming trivial because you only need to override variables, not entire components. And they make responsive design more elegant because fluid values can be centrally defined and used everywhere.

CSS Custom Properties are one of the central tools for professional, modern CSS. They are no longer optional but essential for any larger application. Those who master them write better, more maintainable, more flexible CSS.


☕ Buy me a coffee

If you enjoy my articles and they help you in your work, I would appreciate a "coffee" and a few kind words from you.

Buy me a coffee

Previous Post
Door 12 – State Styling without JavaScript
Next Post
Door 10 – Modern Responsive Design

Comments