EmpireUI
Get Pro
← Blog8 min read#css variables#custom properties#theming

CSS Custom Properties: Dynamic Theming and Animation Tricks

CSS custom properties unlock dynamic theming, live animation control, and component-scoped design tokens — here's how to actually use them well in 2026.

colorful gradient code editor screen with CSS syntax highlighted

What CSS Custom Properties Actually Are

CSS custom properties — officially called "custom properties" but universally called CSS variables — have been in every major browser since 2017. You've probably used them. But most developers stop at --primary-color: #6366f1 and call it done. That's leaving a lot on the table.

Unlike Sass variables, CSS custom properties are live. They exist in the DOM. You can read them, write them, and scope them per element at runtime — without touching a build step. That distinction matters enormously once you start building theme systems or animation rigs.

The basic syntax is simple enough: declare on :root to make something globally available, then consume it with var(--your-property, fallback). The fallback is optional but you'd be surprised how often it saves you during partial migrations.

Scoping: Where the Real Power Lives

Here's where it gets interesting. Custom properties follow normal CSS cascade rules. That means you can declare --card-bg: white on a .dark class and every component inside that element automatically picks up the new value. No prop drilling. No context providers. Just CSS doing CSS things.

In practice, this is how serious design systems work. You define your tokens at :root, then override a subset of them on a theme class. Switching themes is one classList toggle on <html> — done.

Quick aside: scoped variables are also fantastic for component-level customisation. Give a card component its own --card-radius variable defaulting to 8px, and consumers can override it per-instance with a style attribute. That's a better API than most prop interfaces.

Worth noting: the cascade also means you can scope animations. A --duration variable set to 0ms on a .prefers-reduced-motion wrapper will silence all transitions that use it, without hunting down every animation rule in your stylesheet.

Building a Dark Mode System in Under 30 Lines

The classic use case. Here's a minimal but production-ready pattern that doesn't require JavaScript to initialise:

:root {
  --bg: #ffffff;
  --text: #0f0f0f;
  --surface: #f4f4f5;
  --border: #e4e4e7;
  --accent: #6366f1;
}

[data-theme="dark"] {
  --bg: #09090b;
  --text: #fafafa;
  --surface: #18181b;
  --border: #27272a;
  --accent: #818cf8;
}

body {
  background: var(--bg);
  color: var(--text);
  transition: background 200ms, color 200ms;
}

Toggle it from JS with document.documentElement.setAttribute('data-theme', 'dark') and you're done. The 200ms transition on body means the switch feels polished rather than jarring — though you'll want to respect prefers-reduced-motion and drop that to 0ms for users who need it.

This same pattern powers every theme in Empire UI. Glassmorphism, aurora, neobrutalism — they each swap a token layer, not a component library. One reason the glassmorphism components look consistent across light and dark variants is that the blur, border-opacity, and background-alpha are all variables that flip together.

Animating with Custom Properties

This is the part most tutorials skip. CSS custom properties can't be directly interpolated by the browser's animation engine — @keyframes can't tween --hue from 0 to 360 by itself. But you can work around this in a couple of ways.

The first approach uses @property (registered custom properties), supported in Chromium since 2021 and Firefox since 2024. Registering a property tells the browser its type, which lets it interpolate it properly:

@property --hue {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

@keyframes spin-hue {
  to { --hue: 360; }
}

.gradient-text {
  background: hsl(var(--hue), 80%, 60%);
  animation: spin-hue 4s linear infinite;
}

The second approach — useful when you need wider browser support — drives variables from JavaScript. A requestAnimationFrame loop writing element.style.setProperty('--progress', value) gives you full control. This is how most scroll-linked animation systems work, and it's the same technique behind the CSS scroll animations pattern.

Honestly, @property is the cleaner solution when you can use it. The JS approach works fine but you're mixing animation logic across two layers, which gets messy fast on complex UIs.

Design Tokens and Component APIs

If you're building a component library or a design system for a team, CSS custom properties are your public API. Seriously. Every component should expose a documented set of variables that consumers can override — padding, radius, color, shadow depth. This beats a props explosion every time.

Here's a practical button component pattern:

.btn {
  --btn-bg: var(--accent, #6366f1);
  --btn-text: white;
  --btn-radius: 6px;
  --btn-px: 20px;
  --btn-py: 10px;

  background: var(--btn-bg);
  color: var(--btn-text);
  border-radius: var(--btn-radius);
  padding: var(--btn-py) var(--btn-px);
}

/* Consumer override — no class needed */
.hero .btn {
  --btn-radius: 9999px;
  --btn-px: 32px;
}

That 6px default radius is a conscious choice — it reads as modern without being pill-shaped everywhere. Consumers can push it to 9999px for a fully rounded pill in a specific context without touching the component source.

This approach translates directly to how browse the components are structured on Empire UI. The box shadow generator even outputs values as custom property declarations so you can drop them straight into a token file. That's the workflow you want.

Common Mistakes and How to Avoid Them

The most common mistake is trusting the fallback value too much. var(--accent, blue) will output blue if --accent is undefined — but it will also output whatever garbage is in --accent if it's been set to an invalid value by accident. There's no type-checking at this level unless you're using @property.

Second mistake: forgetting that custom properties inherit. If you set --color: red on a parent, every child gets it. That's usually what you want, but it can produce unexpected overrides when you're building isolated components. Scope your variables carefully, or use all: initial as a reset on shadow DOM boundaries.

One more thing — watch out for the space-before-value footgun. --gap: 8px and --gap: 8px (note the leading space in the second one) are different values. The space is part of the value. It won't break your layout visually, but it will break calc(var(--gap) * 2) in subtle ways that are annoying to debug.

That said, none of these are dealbreakers. CSS custom properties are genuinely one of the better additions to the platform in the last decade. Once you build a few theme systems with them, going back to Sass variables or hardcoded values feels prehistoric.

Combining Variables with Modern CSS for 2026 Workflows

CSS custom properties get significantly more powerful when combined with color-mix(), oklch(), and container queries — all now baseline in 2026. You can generate an entire tonal palette from a single hue variable:

:root {
  --brand-hue: 263;
  --brand: oklch(60% 0.2 var(--brand-hue));
  --brand-light: color-mix(in oklch, var(--brand), white 40%);
  --brand-dark: color-mix(in oklch, var(--brand), black 30%);
}

Change --brand-hue at runtime and your entire color system shifts perceptually uniformly. No more manually maintaining tonal scales. This is the approach behind the dynamic theming in the gradient generator tool — one source variable, the rest is derived.

Where does this leave preprocessors? Look, Sass isn't going anywhere for teams with existing codebases. But for greenfield projects in 2026, you can accomplish almost everything you'd reach for Sass for using native CSS — custom properties for variables, @layer for cascade control, and calc() for math. The toolchain gets simpler. That's worth something.

FAQ

Can CSS custom properties be animated without JavaScript?

Yes, if you register them with @property and specify a type like <number> or <color>. Without registration, the browser can't interpolate them, so you'll get a hard snap rather than a smooth tween.

Do CSS custom properties work inside `calc()`?

Absolutely — calc(var(--spacing) * 2) works fine as long as the variable resolves to a number with a unit. Watch out for accidental leading spaces in the value, which can break the calculation silently.

What's the difference between CSS variables and Sass variables?

Sass variables are compiled away at build time — they're static. CSS custom properties exist in the live DOM, can be changed at runtime via JavaScript, and cascade through the element tree. They solve different problems.

How do I scope a CSS variable to a single component?

Declare it on the component's root selector rather than :root. Any descendant will inherit it, but elements outside that component won't be affected. You can also override it per-instance using inline styles.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Advanced CSS Custom Properties: @property, Animatable TokensFigma Variables to CSS Custom Properties: The Workflow That WorksCSS Custom Properties as a Design System: The Right ArchitectureDark Mode Color Tokens: Building a Theme That Doesn't Break Everything