Door 18 – Theming with CSS | CSS Adventskalender
Skip to content

Door 18 – Theming with CSS

Published: at 07:00 AM

Theming is a central topic in UI development today: Light Mode, Dark Mode, High-Contrast themes, various brand variants, or dynamic user settings are standard almost everywhere.

Previously, JavaScript was almost mandatory for this – today, you can map almost the entire theming purely in CSS in modern projects. The basis is CSS Custom Properties, modern color spaces like OKLCH, and functions like color-mix().

This door explains how to professionally build and scale theming in CSS.

Why CSS is Perfectly Suited for Theming

Custom Properties have a fundamental characteristic that makes them ideal for theming: they are available at runtime and inheritable. Unlike variables in preprocessors like Sass or Less, which are compiled to static values at build time, CSS Custom Properties remain dynamic. This means they can change during runtime and these changes are immediately propagated throughout the entire DOM.

This dynamic nature allows themes to be defined at the root node, and all underlying components automatically inherit the values through the cascade. A component doesn’t need to know which theme is currently active. It simply references var(--color-primary) and automatically receives the correct value, regardless of whether Light Mode, Dark Mode, or any other theme is active.

Another decisive advantage is that variables can be overridden without increasing CSS specificity. In traditional CSS architectures, overriding styles often led to specificity battles, where increasingly specific selectors had to be written. With Custom Properties, you simply redefine a variable in a more specific scope, and it overrides the global definition without touching the specificity of the actual style rules.

JavaScript is only used optionally in this approach, perhaps to store the active theme in LocalStorage or to set a data attribute on the root element. But the entire visual switching happens purely in CSS. The result is a clean, component-based theming system that is both flexible and robust and integrates elegantly into modern frontend architectures.

Step 1: Define a Central Token System

Every professional theme system begins with a clean architecture of design tokens. These tokens are not simply variables, but semantic abstractions that define the design language of the product. They function as a single source of truth for all visual properties and ensure that changes can be made centrally without having to touch every individual selector.

:root {
  /* Color values in OKLCH */
  --color-bg: oklch(0.98 0.01 0);
  --color-text: oklch(0.2 0.02 260);

  /* Spacing */
  --space-s: 0.5rem;
  --space-m: 1rem;
  --space-l: 2rem;

  /* Radius */
  --radius: 0.5rem;

  /* Typography */
  --fs-base: clamp(1rem, 0.5vw + 0.9rem, 1.125rem);
}

The use of OKLCH for color values is not by chance. This modern color space is perceptually linear, meaning that numerical changes directly correlate with visual perception. This makes it much easier to create harmonious color palettes and switch between Light and Dark Mode without colors losing their visual identity.

The spacing follows a clear scale that runs through the entire interface and provides vertical and horizontal rhythm. The radius value defines the basic form of all rounded elements and contributes significantly to the visual identity. The typographic base uses clamp() for fluid scaling, so font sizes organically adapt to different viewport sizes without needing hard breakpoints.

These values form the foundation of a consistent theme and are referenced by all components, creating a coherent visual language.

Step 2: Light & Dark Theme via Simple CSS Scopes

The implementation of Light and Dark Mode is elegantly solved through CSS Custom Properties. Instead of requiring separate stylesheets or complex JavaScript logic, you simply define different values for the same variables in different scopes. The crucial advantage: components don’t need to know anything about the active theme, they simply reference the variables, and these automatically deliver the correct values.

/* Light Theme */
:root {
  --color-bg: oklch(0.98 0.01 0);
  --color-text: oklch(0.2 0.01 260);
  --color-primary: oklch(0.62 0.16 260);
}

/* Dark Theme */
[data-theme="dark"] {
  --color-bg: oklch(0.18 0.01 0);
  --color-text: oklch(0.95 0.02 260);
  --color-primary: oklch(0.75 0.16 260);
}

The pattern is remarkably simple. In Light Mode, the background has very high lightness (0.98), while the text is dark (0.2). In Dark Mode, these values invert: the background becomes dark (0.18), the text light (0.95). Crucially, chroma (saturation) and hue (color tone) remain identical. Only the lightness changes, which ensures that color identity is maintained and the theme appears consistent.

Usage in components is extremely simple:

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

This approach avoids any specificity problems because you don’t have to work with selectors like .dark-mode .button. There are no redundant classes that need to be attached to every element. Instead, everything works through pure var()-based styling that elegantly inherits through the entire DOM. A single data attribute on the root element is enough to switch the entire interface.

Step 3: System-based Theming (prefers-color-scheme)

One of the most elegant features for modern theming is the ability to automatically respond to the user’s system settings. The prefers-color-scheme media query allows detection of whether a user prefers Light or Dark Mode at the operating system level, and adjusts the theme accordingly. This respects user preferences and ensures that the website seamlessly integrates into the familiar environment.

Implementation can be done in various ways. The simplest approach sets a variable that can then be used by other rules:

@media (prefers-color-scheme: dark) {
  :root {
    --theme-mode: dark;
  }
}

Alternatively, theme colors can be defined directly in the media query:

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: oklch(0.18 0.01 0);
    --color-text: oklch(0.95 0.02 260);
    --color-primary: oklch(0.75 0.16 260);
  }
  
  html {
    color-scheme: dark;
  }
}

This approach combines both strategies: the theme variables are automatically adjusted, and the color-scheme property signals to the browser that the page is optimized for Dark Mode. This has far-reaching effects on native browser elements. Form controls like checkboxes, radio buttons, and select fields automatically adapt and are displayed in their dark variant. Scrollbars also receive a darker appearance that better matches the theme. Input fields automatically get adjusted background and text colors without having to define them manually. Even background shadings that the browser adds for certain elements are automatically adapted to the dark theme.

This automatic adaptation not only saves a lot of code but also ensures that native elements appear consistent with the rest of the page without having to manually style every single element.

JavaScript Integration (optional): If users should be able to manually switch between themes, a single line of JavaScript is sufficient:

// Switch theme
document.documentElement.dataset.theme = 'dark';

// Load theme from LocalStorage
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
  document.documentElement.dataset.theme = savedTheme;
}

This code sets the data-theme attribute on the <html> element, and CSS automatically handles the rest.

Step 4: Brand Themes – Dynamic Color Worlds

Brand themes are one of the most powerful applications of CSS theming. They enable completely different visual identities to be supported with minimal code overhead. Instead of maintaining separate stylesheets for each brand, you simply define different values for the same variables. The rest of the system remains completely unchanged.

Encapsulation occurs through data attributes that can be flexibly set at various levels of the DOM:

[data-brand="blue"] {
  --brand: oklch(0.62 0.15 250);
}

[data-brand="green"] {
  --brand: oklch(0.65 0.14 140);
}

All components that use the brand color simply reference the variable:

.button {
  background-color: var(--brand);
}

As soon as the data attribute data-brand="green" is set on the root element or a container, all underlying elements automatically adopt the green brand color. No JavaScript is required, no complex logic, just setting a single attribute.

This approach is particularly valuable for products that need to support different brand identities. White-Label UIs, where the same product is sold under different brand names with their own visual identity, can be realized with minimal effort. Tenant design systems in B2B software, where each customer wants their own corporate design, can be elegantly mapped via theme variables. Multi-brand products, where a company operates multiple brands under one technical roof, also benefit from this pattern, as the brand identity can be controlled purely via CSS variables without changing the underlying code.

Step 5: Generating Color Variants via color-mix()

A decisive advantage of modern CSS theming systems is the ability to generate color variants dynamically from base colors. The color-mix() function makes it possible to create complete color scales without having to manually define every single gradation. This not only reduces code overhead but also ensures that all variants are automatically harmoniously coordinated.

:root {
  --brand: oklch(0.62 0.16 260);

  --brand-100: color-mix(in oklch, var(--brand) 20%, white);
  --brand-200: color-mix(in oklch, var(--brand) 40%, white);
  --brand-300: color-mix(in oklch, var(--brand) 60%, white);

  --brand-600: color-mix(in oklch, var(--brand) 80%, black);
  --brand-700: color-mix(in oklch, var(--brand) 60%, black);
}

The mixing occurs in the OKLCH color space, which is crucial. Unlike RGB or HSL, mixtures in OKLCH are perceptually linear and produce more natural, harmonious transitions. The lighter variants (100, 200, 300) are created by mixing with white, the darker ones (600, 700) by mixing with black. The result is a complete color scale that is harmonious and consistent – and all without manual color selection or design tools.

This approach is particularly valuable for dynamic themes. If the base color --brand changes, all derived variants automatically adjust. This is ideal for white-label products or themes where users can define their own colors.

Step 6: Making Components Themeable

The art of a good theme system lies in making components both globally themeable and locally overrideable. The pattern that has proven successful uses component-specific variables with fallback values. This allows components to be coupled to the global theme while permitting local adjustments without specificity problems.

.card {
  background: var(--card-bg, var(--color-bg));
  color: var(--card-text, var(--color-text));
  border-radius: var(--radius);
}

The syntax var(--card-bg, var(--color-bg)) defines a fallback mechanism. If --card-bg is not defined, --color-bg is used. This means: by default, the card behaves like all other components and adopts the global theme colors.

But as soon as you need a specific variant, you simply set the component’s own variables:

.card--highlight {
  --card-bg: var(--brand-100);
  --card-text: var(--brand-700);
}

This modifier locally overrides the card variables without touching the global structure. This is extremely flexible because you can change the entire appearance of the component with a single variable declaration. At the same time, it is memory-efficient and performant because no redundant styles need to be defined and inheritance works automatically.

Step 7: Theming Without Specificity – With Layers

In larger projects, controlling CSS specificity increasingly becomes a challenge. Different developers add styles, component libraries bring their own styles, and overrides must become increasingly specific. CSS Cascade Layers elegantly solve this problem by defining an explicit hierarchy that works independently of selector specificity.

For theme systems, a clear layer structure is recommended:

@layer reset, theme, components, overrides;

@layer theme {
  :root {
    --color-bg: oklch(0.98 0.01 0);
  }

  [data-theme="dark"] {
    --color-bg: oklch(0.18 0.01 0);
  }
}

This layer hierarchy defines the priority: reset for browser resets, theme for all theme variables, components for component styles, and overrides for project-specific adjustments. The crucial advantage: a style in the overrides layer always overrides a style in the theme layer, regardless of selector specificity. You don’t have to resort to tricks like increased specificity or !important.

CSS Cascade Layers bring order to the specificity hierarchy of theme definitions. Through the explicit layer structure, theme definitions remain stable because they reside in a defined layer of the cascade and cannot be overridden by random specificity differences. At the same time, they remain deliberately overrideable, as layers with higher priority (like the overrides layer) can intentionally override theme values without having to resort to !important or artificially increase specificity. The structure remains clear and comprehensible because the layer hierarchy is explicitly declared at the beginning of the stylesheet, making it immediately visible to every developer which styles take effect in which order.

Step 8: High-Contrast Themes

Accessibility is no longer a nice-to-have today, but a fundamental requirement for modern web products. High-Contrast themes are an essential component of this strategy and must be considered from the beginning in the theme system. They are aimed at people with visual impairments, but also at users in difficult lighting conditions or with certain cognitive limitations.

[data-theme="high-contrast"] {
  --color-bg: oklch(1 0 0);
  --color-text: oklch(0 0 0);
  --color-primary: oklch(0.85 0.25 20);
}

The color definitions are deliberately chosen to be extreme. The background is pure white (lightness 1, chroma 0), the text pure black (lightness 0, chroma 0). This results in a maximum contrast ratio of 21:1, which significantly exceeds the WCAG AAA standard. The primary color has high lightness (0.85) and strong saturation (0.25), so it remains clearly visible on a white background while still conveying color information.

High-Contrast themes are essential for accessibility and make it possible to deliberately increase contrasts so that even people with visual impairments or in difficult lighting conditions can easily perceive all content. By deliberately reducing color saturation, distracting color effects are minimized, which is particularly helpful for people with certain cognitive limitations or color vision deficiencies. UI elements can be highlighted more clearly through clearer color contrasts, making interactive areas immediately recognizable and improving usability.

Such themes are ideal for meeting WCAG guidelines at the AA or AAA level, as they significantly exceed the required contrast ratios and thus ensure barrier-free access for a broad spectrum of users.

Practical Example: Complete Setup for Light/Dark/Brand

A complete, production-ready theme system doesn’t have to be complex. On the contrary: elegance lies in simplicity. The following example shows how to build a theme system with minimal code that supports Light Mode, Dark Mode, and various brand variants while remaining fully maintainable and extensible.

:root {
  --surface: oklch(0.98 0.01 0);
  --text: oklch(0.2 0.02 260);
  --brand: oklch(0.62 0.16 260);
}

[data-theme="dark"] {
  --surface: oklch(0.18 0.01 0);
  --text: oklch(0.95 0.02 260);
  --brand: oklch(0.75 0.16 260);
}

[data-brand="green"] {
  --brand: oklch(0.65 0.14 140);
}

.button {
  background: var(--brand);
  color: var(--text);
}

.card {
  background: var(--surface);
  color: var(--text);
}

This setup is deliberately kept minimalistic, but it works. The base tokens define Surface (background surfaces), Text, and Brand (brand color). Dark Mode overrides these tokens with inverted lightness values while chroma and hue remain consistent. Brand variants can be defined separately and only override the brand color while all other values remain unchanged.

The result is a complete theme system in under 20 lines of CSS that works robustly, is easily extensible, and integrates seamlessly into any existing architecture. Components like buttons and cards simply reference the tokens and automatically adopt the active theme without having to implement their own theme logic.

Browser Support and Performance

Browser Support: All modern browsers support the presented techniques very well. CSS Custom Properties work in all browsers except Internet Explorer 11. The color-scheme property and prefers-color-scheme media query are supported by Chrome 76+, Firefox 67+, Safari 12.1+, and Edge 79+. The color-mix() function is available in Chrome 111+, Firefox 113+, and Safari 16.2+. CSS Cascade Layers are supported from Chrome 99+, Firefox 97+, and Safari 15.4+. For older browsers, fallbacks with static colors are recommended.

Performance: CSS Custom Properties have minimal performance overhead that is not measurable in practice. The browser caches values efficiently and only recalculates them when they change. Compared to JavaScript-based theming solutions, the CSS approach is significantly more performant because no DOM manipulations or re-renderings are required. All theming runs in the browser’s CSS engine and is thus optimally optimized. Even with complex theme systems containing hundreds of variables, performance remains excellent.

Conclusion

Modern CSS theming has evolved into a robust and powerful tool that works without JavaScript yet enables highly flexible theme systems. At its core, this approach is based on CSS Custom Properties, which are available as dynamic variables at runtime and can be elegantly inherited. The integration of modern color spaces like OKLCH ensures that colors scale perceptually linear and can be transformed consistently between Light and Dark Mode without losing visual identity.

The color-scheme property communicates with the browser and ensures that native elements automatically match the active theme. The prefers-color-scheme media query makes it possible to respond to the user’s system settings and automatically adjust the theme. The color-mix() function allows color variants to be generated dynamically from base colors without having to manually define every gradation. All these techniques come together to form a clear token system that serves as a single source of truth for all visual properties.

The result is impressive: Light and Dark themes can be implemented with minimal code overhead, Brand themes can be flexibly defined for different tenants or brands, and special variants like High-Contrast themes for accessibility can be seamlessly integrated. Maintainability is excellent because changes are made centrally in one place and automatically propagate through the entire system. And all of this works robustly and performantly without requiring JavaScript for the actual theme logic.


☕ 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 19 – Transitions and Microinteractions
Next Post
Door 17 – CSS Color Level 5

Comments