CSS Custom Properties at Runtime: Theming, JS Bridges and @property
Learn how CSS custom properties differ from Sass variables and use them for theming, JavaScript-driven effects, fluid type, component variants and typed animation.
This article is published in English.
Sources: CSS Variables Are Underrated — Here’s What You’re Missing
Many codebases use CSS custom properties exactly like Sass variables: a list of colors declared once on :root and never touched again. That works, but it ignores the feature that makes them different. Custom properties are live values in the browser that inherit through the DOM, respond to media queries and attribute changes, and can be updated from JavaScript at any moment. This guide shows what that runtime behavior enables in practice, from theming and parameterized components to typed animation with @property, and points out the mechanics and pitfalls that trip teams up.
Compile-Time Constants Versus Live Values
Preprocessors such as Sass, Less and Stylus shaped frontend styling for years with nesting, mixins and variables. When native custom properties arrived in browsers, a common reaction was that they added nothing a $primary-color did not already provide. That assumption is the root of the underuse. The difference is when the value exists:
+-------------------------------------------------------------------+
| SASS / SCSS VARIABLES |
| Compile-Time Constants --> Evaluated Before Ship --> Static |
+-------------------------------------------------------------------+
+-------------------------------------------------------------------+
| CSS CUSTOM PROPERTIES |
| Runtime Live Properties --> Evaluated in Browser --> Dynamic |
+-------------------------------------------------------------------+
What a preprocessor variable becomes
A Sass variable is resolved before the CSS ever reaches the browser. In the following SCSS, a second value is assigned inside the dark modifier block:
$brand-color: #3b82f6;
.card {
background-color: $brand-color;
}
.card--dark {
$brand-color: #1e293b;
background-color: $brand-color;
}
The compiler substitutes the literal value everywhere the variable is used, so the shipped stylesheet is simply this:
.card {
background-color: #3b82f6;
}
.card--dark {
background-color: #1e293b;
}
After compilation the name $brand-color no longer exists. The browser cannot know that both colors came from one variable, and it cannot change that value in response to a click, a viewport change or a hover state. Every variation has to be generated as additional CSS in advance.
What a custom property stays
The native version keeps the name alive in the browser:
:root {
--brand-color: #3b82f6;
}
.card {
background-color: var(--brand-color);
}
.card[data-theme="dark"] {
--brand-color: #1e293b;
}
Here --brand-color is part of each element's computed style. When a .card gains data-theme="dark", the browser recomputes var(--brand-color) for that card and its descendants. The .card rule is not duplicated; you only change the value for one branch of the DOM tree. In short: Sass variables are replaced during the build and are gone at runtime, while custom properties are resolved by the browser per element, participate in the cascade and can change after the page loads.
The Mechanics You Need to Get Right
A few rules about syntax, scope and error handling explain most of the surprising behavior people run into.
Naming and reading values
A custom property name must start with two dashes, and names are case-sensitive, so these are two separate properties:
:root {
--main-color: #10b981;
--Main-Color: #ef4444; /* Different property! */
}
You read a value with the var() function:
.button {
background-color: var(--main-color);
}
Scope follows the cascade
Custom properties obey normal cascade and inheritance. Declaring one on :root, which matches the <html> element, makes it available everywhere. Declaring it on another selector overrides it only for that element and its descendants:
/* Global Scope */
:root {
--spacing-unit: 1rem;
}
/* Sub-tree Scope Override */
.sidebar {
--spacing-unit: 0.5rem; /* Overrides --spacing-unit for .sidebar and its descendants */
}
.sidebar .widget {
padding: var(--spacing-unit); /* Computes to 0.5rem */
}
.main-content .widget {
padding: var(--spacing-unit); /* Computes to 1rem */
}
Both widgets use the same rule, yet the one inside .sidebar gets the smaller spacing because the nearest declaration wins. This is what makes scoped overrides so cheap: you change a value, not a rule.
Fallback values
var() accepts a second argument that is used when the referenced property has no value. Fallbacks can themselves contain var() calls, so you can chain them:
.avatar {
/* Fallback to standard 40px if --avatar-size is undefined */
width: var(--avatar-size, 40px);
/* Nested fallbacks */
color: var(--user-theme-color, var(--brand-default, #2563eb));
}
Be precise about when a fallback applies. It is used only when the custom property is not defined for that element (nor inherited from an ancestor) or is explicitly set to initial. If the property is defined but holds a value that makes no sense for the target, such as a length where a color is expected, the fallback is ignored. Note that unset is not a reliable way to trigger it, because custom properties inherit by default, so unset behaves like inherit and picks up the parent's value if there is one.
Invalid at computed-value time
An ordinary declaration with an invalid value is thrown away at parse time, so an earlier valid declaration still applies. Declarations containing var() cannot be checked while parsing, because the substituted value is unknown until the cascade runs:
.box {
color: red;
color: var(--not-a-color, 12px); /* Invalid type for color! */
}
The parser accepts the second color line and it overrides color: red. Only at computed-value time does the browser discover that 12px is not a color. At that point the earlier declaration has already lost, so the property behaves as if it were unset: inherited properties such as color take the parent's value, and non-inherited ones fall back to their initial value (for example, transparent for background-color). This silent failure is one of the problems that @property, covered later, is designed to address.
Theming Without Duplicated Rules
A classic dark mode implementation duplicates every themed rule:
/* Traditional approach: Duplicating declarations */
.card {
background-color: #ffffff;
color: #1e293b;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
}
body.dark-mode .card {
background-color: #0f172a;
color: #f8fafc;
border: 1px solid #334155;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.5);
}
With a few properties this is manageable. With dozens of components, each with many themed declarations, every theme multiplies the selectors you have to keep in sync.
Tokens make components theme-agnostic
If design decisions live in custom properties, component rules stop caring which theme is active:
/* 1. Define Design Tokens */
:root {
--bg-surface: #ffffff;
--text-primary: #1e293b;
--border-subtle: #e2e8f0;
--shadow-alpha: 0.05;
}
[data-theme="dark"] {
--bg-surface: #0f172a;
--text-primary: #f8fafc;
--border-subtle: #334155;
--shadow-alpha: 0.5;
}
/* 2. Write Component CSS Once */
.card {
background-color: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border-subtle);
box-shadow: 0 4px 6px rgba(0, 0, 0, var(--shadow-alpha));
}
The .card rule exists once. Adding a third theme, such as [data-theme="high-contrast"], means supplying four token values rather than redeclaring rules for every component.
Deriving a palette from color channels
You can take this further by storing the individual channels of a color, for example in OKLCH, and computing every variation from them:
:root {
/* Store raw OKLCH channels: Lightness, Chromaticity, Hue */
--brand-l: 0.62;
--brand-c: 0.19;
--brand-h: 255;
/* Base dynamic color */
--brand-color: oklch(var(--brand-l) var(--brand-c) var(--brand-h));
/* Automatically derived variations */
--brand-hover: oklch(calc(var(--brand-l) - 0.1) var(--brand-c) var(--brand-h));
--brand-active: oklch(calc(var(--brand-l) - 0.2) var(--brand-c) var(--brand-h));
--brand-tint: oklch(0.95 calc(var(--brand-c) * 0.2) var(--brand-h));
--brand-alpha-50: oklch(var(--brand-l) var(--brand-c) var(--brand-h) / 0.5);
}
.button-primary {
background-color: var(--brand-color);
color: white;
}
.button-primary:hover {
background-color: var(--brand-hover);
}
.button-primary:active {
background-color: var(--brand-active);
}
.badge {
background-color: var(--brand-tint);
color: var(--brand-color);
}
Hover, active, tint and translucent variants are all expressions over three numbers. Changing --brand-h on :root, from JavaScript or a user preference, recolors the base color, its states, the tint and the overlays consistently, without a color manipulation library.
One subtlety applies to every derived token: a var() inside a custom property is resolved on the element where that property is declared, and descendants inherit the resulting value. Because --brand-color and friends are declared on :root, overriding --brand-h deeper in the tree does not recompute them there. Either change the channel on :root, or redeclare the derived tokens in the scope where you override the channel.
Driving CSS From JavaScript
Custom properties are a clean interface between scripts and styles. Instead of writing inline style strings or juggling class combinations, JavaScript can hand raw values, such as pointer coordinates or application state, to CSS and let the stylesheet decide what to do with them.
A cursor-following highlight
Start with a card element:
<div class="glow-card" id="card">
<h3>Interactive Reactivity</h3>
<p>Hover over this card to see real-time cursor lighting.</p>
</div>
The stylesheet declares default coordinates and uses them to position a radial gradient in a pseudo-element:
.glow-card {
--mouse-x: 50%;
--mouse-y: 50%;
position: relative;
background-color: #1e1e2e;
border-radius: 12px;
padding: 2rem;
color: #cdd6f4;
overflow: hidden;
}
/* Radial light overlay following cursor coordinates */
.glow-card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
800px circle at var(--mouse-x) var(--mouse-y),
rgba(255, 255, 255, 0.08),
transparent 40%
);
pointer-events: none;
}
The script only reports where the pointer is, relative to the card:
const card = document.getElementById('card');
card.addEventListener('pointermove', (e) => {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Send raw coordinates to CSS
card.style.setProperty('--mouse-x', `${x}px`);
card.style.setProperty('--mouse-y', `${y}px`);
});
This division of labor is the appeal: JavaScript writes two numbers on one element and never touches the gradient itself. It is not free, though. Each update triggers a style recalculation for the card, and because the gradient changes, the overlay is repainted. That cost is small for a single element, which is why the effect usually runs smoothly, but it is not "zero reflow" work that happens off the main thread.
A scroll progress bar
The same idea can pass scroll position into CSS as a unitless fraction between 0 and 1:
window.addEventListener('scroll', () => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const scrollPercent = (scrollTop / docHeight);
// Set unitless fraction (0 to 1)
document.documentElement.style.setProperty('--scroll-progress', scrollPercent);
});
The bar consumes it through transform, which avoids layout work because scaling does not change the size of anything else on the page:
/* Progress indicator bar */
.scroll-progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
width: 100%;
background-color: #3b82f6;
transform-origin: left;
/* Hardware-accelerated GPU transform using variable */
transform: scaleX(var(--scroll-progress, 0));
will-change: transform;
}
Two refinements are worth considering. The script writes to document.documentElement, which is exactly the global, high-frequency update that the performance section below warns against; setting the value on the bar itself confines recalculation to one element. And in browsers that support CSS scroll-driven animations, a progress bar like this can be built with animation-timeline: scroll() and no JavaScript at all.
Fluid Typography and Scale Ratios
Breakpoint-based typography jumps between fixed sizes at widths such as 768px and 1024px. Combining custom properties with clamp() and calc() lets sizes change continuously between a minimum and a maximum instead.
A parameterized clamp() calculation
The idea is to describe the scale with plain numbers (viewport range and font size range) and derive the slope and intercept of a linear function between them:
:root {
/* Viewport and layout parameters */
--fluid-min-width: 320;
--fluid-max-width: 1200;
/* Typography scale parameters */
--font-min-size: 16;
--font-max-size: 20;
/* Compute slope and intersection for clamp() */
--slope: (var(--font-max-size) - var(--font-min-size)) / (var(--fluid-max-width) - var(--fluid-min-width));
--y-axis-intersection: -1 * var(--fluid-min-width) * var(--slope) + var(--font-min-size);
/* Fluid base font size expression */
--fluid-type-base: clamp(
calc(var(--font-min-size) * 1px),
calc(var(--y-axis-intersection) * 1px + (var(--slope) * 100vw)),
calc(var(--font-max-size) * 1px)
);
}
body {
font-size: var(--fluid-type-base);
}
Treat this snippet as a starting point rather than working code. Custom property substitution is textual, so when --y-axis-intersection is dropped into calc(var(--y-axis-intersection) * 1px + ...), the * 1px binds only to the last term of that expression. The unitless first term then gets added to a length, which makes the whole clamp() invalid. Wrapping the intermediate expressions in parentheses, for example --y-axis-intersection: (-1 * var(--fluid-min-width) * var(--slope) + var(--font-min-size));, or in calc(), preserves the intended grouping.
Harmonic heading sizes from one ratio
With a base size in place, a single ratio can generate the entire heading scale:
:root {
/* Scale multiplier ratio (e.g., 1.25 Major Third) */
--type-ratio: 1.25;
--step-0: var(--fluid-type-base);
--step-1: calc(var(--step-0) * var(--type-ratio));
--step-2: calc(var(--step-1) * var(--type-ratio));
--step-3: calc(var(--step-2) * var(--type-ratio));
--step-4: calc(var(--step-3) * var(--type-ratio));
}
/* Application across elements */
p { font-size: var(--step-0); }
h3 { font-size: var(--step-1); }
h2 { font-size: var(--step-2); }
h1 { font-size: var(--step-3); }
Each step multiplies the previous one, so the proportions between levels stay consistent. Changing the ratio inside a media query recalculates every level at once:
@media (min-width: 1024px) {
:root {
/* Upgrade to 1.333 Perfect Fourth on large displays */
--type-ratio: 1.333;
}
}
This works because the steps and the ratio are declared on the same element, :root, so the override is visible when the steps are computed.
Components With Parameters Instead of Variant Classes
Variant classes in BEM or similar methodologies tend to multiply: every size, shape and color becomes another block of declarations that partially repeats the base.
/* Traditional class bloat */
.btn { padding: 8px 16px; font-size: 14px; background: blue; border-radius: 4px; }
.btn--md { padding: 12px 24px; font-size: 16px; }
.btn--lg { padding: 16px 32px; font-size: 18px; }
.btn--rounded { border-radius: 9999px; }
.btn--danger { background: red; }
.btn--success { background: green; }
Custom properties as a component's API
A more robust approach treats custom properties as the component's configuration. The base rule reads every adjustable value from a property with a default, and modifiers only change parameters:
.btn {
/* 1. Component Defaults */
--btn-bg: #3b82f6;
--btn-color: #ffffff;
--btn-px: 1.25rem;
--btn-py: 0.625rem;
--btn-radius: 0.375rem;
--btn-font-size: 0.875rem;
/* 2. Unified Rule Declaration */
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--btn-py) var(--btn-px);
background-color: var(--btn-bg);
color: var(--btn-color);
border-radius: var(--btn-radius);
font-size: var(--btn-font-size);
border: none;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}
.btn:hover {
filter: brightness(0.9);
}
/* 3. Concise Declarative Modifiers */
.btn--lg {
--btn-px: 1.75rem;
--btn-py: 0.875rem;
--btn-font-size: 1.125rem;
}
.btn--pill {
--btn-radius: 9999px;
}
.btn--danger {
--btn-bg: #ef4444;
}
.btn--ghost {
--btn-bg: transparent;
--btn-color: #3b82f6;
}
Modifiers such as .btn--lg do not repeat display, border-radius, cursor or transition. They only set new values for the parameters. The stylesheet stays small, modifiers compose naturally (btn--lg btn--pill btn--danger just works), and when a variant looks wrong you know it can only be one of a few tokens.
Typed Properties and Animation With @property
Unregistered custom properties have a well-known limitation: they cannot be interpolated in transitions or keyframes. The browser treats their values as untyped token sequences, so it has no way to animate from 0deg to 360deg or from red to blue; the value simply flips at some point during the animation.
The @property at-rule registers a custom property with a syntax, an initial value and an inheritance setting. It is supported in current versions of the major browsers, but check your support targets before relying on it.
Registering typed properties
/* Register type-checked custom property */
@property --gradient-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@property --accent-color {
syntax: "<color>";
inherits: true;
initial-value: #3b82f6;
}
Registration also fixes the silent-failure problem from earlier: a value that does not match the declared syntax is rejected, and the property falls back to its registered initial value instead of an unexpected inherited one.
Transitions on gradients
Once the browser knows --gradient-angle is an angle and --accent-color is a color, it can interpolate them, which makes a gradient transition possible:
.animated-gradient-card {
/* Reference the typed property inside radial/linear gradient */
background: linear-gradient(
var(--gradient-angle),
#0f172a,
var(--accent-color),
#0f172a
);
border-radius: 16px;
padding: 3rem;
/* Animate custom property on hover! */
transition: --gradient-angle 0.8s ease, --accent-color 0.5s ease;
}
.animated-gradient-card:hover {
--gradient-angle: 180deg;
--accent-color: #ec4899;
}
Continuous keyframe animation
The same registration enables keyframes that rotate a gradient indefinitely:
@keyframes spin-gradient {
to {
--gradient-angle: 360deg;
}
}
.glowing-border {
animation: spin-gradient 4s linear infinite;
}
Keep in mind that animating a registered property used in a gradient still repaints the element each frame on the main thread; it is not the same as a compositor-only transform or opacity animation.
Performance: Where Updates Hurt
Custom properties are not slow in themselves. The cost depends on how much of the tree an update invalidates.
How far an update reaches
Calling element.style.setProperty('--name', value) causes a style invalidation for that element and everything that might inherit from it:
+-------------------------------------------------------------------------+
| STYLE INVALIDATION CASCADE PATTERN |
| |
| [ :root Variable Mutation ] |
| └─> Invalidates Entire DOM Tree |
| └─> High Recalculation Cost (Large DOMs) |
| |
| [ Scoped Local Element Mutation ] |
| └─> Invalidates Only Local Component Branch |
| └─> Minimal Engine Cost (60 FPS Safe) |
+-------------------------------------------------------------------------+
- Changing a property on
:rootcan force the browser to recompute styles for every element that depends on it anywhere in the document. Doing that on everyscrollormousemoveevent on a large page can cost frames. - Setting the property on a specific element, such as the card being hovered, limits recalculation to that branch.
Practical guidelines
First, send high-frequency updates such as pointer positions or scroll progress to the component that uses them, not to :root.
Second, for values that no descendant needs, register them with inherits: false, which limits how far changes propagate during animation:
@property --local-x {
syntax: "<length>";
inherits: false; /* Performance booster! Stops inheritance traversal */
initial-value: 0px;
}
There is an important catch: pseudo-elements inherit from their originating element, so a non-inherited registered property set on an element is not visible inside that element's ::before or ::after. If a pseudo-element reads the value, keep the property inherited or apply the effect on the element itself.
Third, when a value feeds motion, consume it through transform or opacity so the change skips layout and, where possible, paint:
/* Performant GPU layer usage */
.box {
transform: translate3d(var(--x, 0), var(--y, 0), 0);
}
Putting It Together: A Themed Dashboard Card
The following widget combines the techniques: two themes, a cursor-following glow, a hue control that recolors the component, and fluid heading sizes. The markup is a semantic <article> with a header, a toggle button and a range input:
<article class="dash-card" id="dashCard" data-theme="dark">
<header class="dash-card__header">
<span class="dash-card__badge">Live Metrics</span>
<button class="dash-card__theme-btn" id="themeToggle">Toggle Theme</button>
</header>
<div class="dash-card__body">
<h2 class="dash-card__title">CSS Variable Architecture</h2>
<p class="dash-card__text">
Hover over this component to trigger lighting updates. Use the color slider below to adjust primary hue channels across all child components in real time.
</p>
<div class="dash-card__controls">
<label for="hueSlider">Primary Hue: <span id="hueVal">220</span>°</label>
<input type="range" id="hueSlider" min="0" max="360" value="220">
</div>
</div>
</article>
The stylesheet is organized in three layers: registered properties and global tokens, theme scopes on the card, and component rules that only read tokens:
/* ==========================================================================
1. DESIGN TOKENS & SYSTEM SCALARS
========================================================================== */
@property --card-glow-x {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --card-glow-y {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
:root {
/* Default Color Channels */
--hue: 220;
--chroma: 0.18;
/* Derived Dynamic Palette */
--color-primary: oklch(0.60 var(--chroma) var(--hue));
--color-primary-hover: oklch(0.50 var(--chroma) var(--hue));
--color-primary-tint: oklch(0.95 calc(var(--chroma) * 0.3) var(--hue));
/* Layout Dimensions */
--radius-lg: 1rem;
--radius-sm: 0.375rem;
}
/* ==========================================================================
2. THEME SCOPES
========================================================================== */
.dash-card[data-theme="light"] {
--bg-card: #ffffff;
--bg-card-border: #e2e8f0;
--text-main: #0f172a;
--text-muted: #64748b;
--glow-opacity: 0.12;
}
.dash-card[data-theme="dark"] {
--bg-card: #0f172a;
--bg-card-border: #1e293b;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--glow-opacity: 0.25;
}
/* ==========================================================================
3. COMPONENT DECLARATION
========================================================================== */
.dash-card {
position: relative;
width: 100%;
max-width: 480px;
background-color: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-lg);
padding: 2rem;
color: var(--text-main);
overflow: hidden;
transition: background-color 0.3s, border-color 0.3s, color 0.3s;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
}
/* Interactive Glow Accent Layer */
.dash-card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
600px circle at var(--card-glow-x) var(--card-glow-y),
oklch(0.70 var(--chroma) var(--hue) / var(--glow-opacity)),
transparent 60%
);
pointer-events: none;
transition: opacity 0.3s;
}
/* Header Elements */
.dash-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.dash-card__badge {
padding: 0.25rem 0.75rem;
background-color: var(--color-primary-tint);
color: var(--color-primary);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dash-card__theme-btn {
background: transparent;
border: 1px solid var(--bg-card-border);
color: var(--text-main);
padding: 0.5rem 0.875rem;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
transition: background-color 0.2s;
}
.dash-card__theme-btn:hover {
background-color: var(--color-primary-tint);
}
/* Content Elements */
.dash-card__title {
font-size: clamp(1.25rem, 2vw + 1rem, 1.75rem);
margin-bottom: 0.75rem;
color: var(--text-main);
}
.dash-card__text {
font-size: 0.95rem;
line-height: 1.6;
color: var(--text-muted);
margin-bottom: 1.5rem;
}
.dash-card__controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: oklch(0.50 var(--chroma) var(--hue) / 0.05);
padding: 1rem;
border-radius: var(--radius-sm);
}
.dash-card__controls label {
font-size: 0.875rem;
font-weight: 600;
}
The script connects the controls to those tokens, always writing on the card itself rather than on :root:
/* ==========================================================================
4. REACTIVE SCRIPT BRIDGE
========================================================================== */
const card = document.getElementById('dashCard');
const themeBtn = document.getElementById('themeToggle');
const hueSlider = document.getElementById('hueSlider');
const hueVal = document.getElementById('hueVal');
// 1. Mouse coordinate tracking (Targeted scope prevents full layout invalidation)
card.addEventListener('pointermove', (e) => {
const rect = card.getBoundingClientRect();
card.style.setProperty('--card-glow-x', `${e.clientX - rect.left}px`);
card.style.setProperty('--card-glow-y', `${e.clientY - rect.top}px`);
});
// 2. Interactive Theme Switcher
themeBtn.addEventListener('click', () => {
const currentTheme = card.getAttribute('data-theme');
const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
card.setAttribute('data-theme', nextTheme);
});
// 3. Dynamic Color Engine Adjustment
hueSlider.addEventListener('input', (e) => {
const newHue = e.target.value;
hueVal.textContent = newHue;
// Update hue variable locally on component container
card.style.setProperty('--hue', newHue);
});
Before shipping a component like this, verify two details in the browser, because both follow from the mechanics described above:
- The glow coordinates are registered with
inherits: falsebut consumed in.dash-card::before. Since the pseudo-element does not inherit them, it sees the initial0pxvalues and the light stays in the corner. Registering them withinherits: true, or leaving them unregistered, lets the effect follow the pointer. - The slider sets
--hueon the card, but--color-primaryand--color-primary-tintare computed on:root, so the badge keeps its original color. Only the rules that usevar(--hue)directly (the glow and the controls background) respond. Redeclaring the derived palette on.dash-cardmakes the whole component follow the slider.
Key Takeaways
- Move the values that vary between themes and states into custom properties, and write each component rule once.
- Declare tokens at the level where they belong, whether
:root, a layout region or a single component, and let inheritance carry them down. Remember that derived tokens are computed where they are declared. - Let JavaScript pass raw values with
element.style.setProperty()instead of building style strings, and target the smallest element that needs the value. - Register properties with
@propertyand a syntax such as<color>,<angle>or<length>to get type checking and smooth interpolation. - Store color channels in HSL or OKLCH to derive shades, states and transparency without a library.
- Keep frequent updates away from
:root, prefertransformandopacityfor motion, and useinherits: falseonly where no descendant or pseudo-element needs the value.
Custom properties are not a replacement for preprocessor syntax so much as a different model: styles that know about the DOM, react to interaction and can be steered at runtime. Used that way, they make design systems smaller and easier to change.