To read a CSS custom property in JavaScript you call getComputedStyle(element).getPropertyValue('--name'), and to set one you call element.style.setProperty('--name', value). That asymmetry trips people up: you read from the computed style but write to the inline .style object. Here is the whole thing in two lines:
// Read --accent off the root element (and trim, see below)
const accent = getComputedStyle(document.documentElement)
.getPropertyValue('--accent')
.trim();
// Set it
document.documentElement.style.setProperty('--accent', '#e23744');The rest of this page is the detail that the two-liner hides: the whitespace gotcha on reads, where the value actually lives (inline vs the cascade), setting variables globally vs on one element, and the reason this matters at all, which is that changing one variable re-styles every rule that references it, live, with no per-element DOM work.
Reading a custom property: mind the whitespace
getPropertyValue returns the value as a string, and it is often padded with a leading space. If you write --accent: #e23744; in CSS, the read can come back as " #e23744", with that leading space intact. Compare it against "#e23744" and the check fails for no visible reason. Always .trim() the result:
const raw = getComputedStyle(document.documentElement)
.getPropertyValue('--accent'); // " #e23744" (note the space)
const accent = raw.trim(); // "#e23744"A second thing to know: getComputedStyle returns the value as authored, not as resolved. A custom property is a substitution token, so the browser does not compute it the way it computes, say, width. If --gap is set to 1rem, reading --gap gives you back the string "1rem", not "16px". If you need the pixel value, read the property that consumes the variable (read gap, not --gap), or do the math yourself.
getComputedStyle also has to run against an element that is in the document. Call it on a detached node and you get empty strings back, because the cascade has not been applied to anything off-DOM. This is the usual cause of "my variable reads as empty" right after creating an element in JS.
Setting a custom property
setProperty writes to the element's inline style, exactly as if you had typed style="--accent: #e23744" on the tag:
const el = document.querySelector('.card');
el.style.setProperty('--accent', '#0a7d4b');You can pass a priority as the third argument the same way you would for any property (el.style.setProperty('--accent', '#0a7d4b', 'important')), and removeProperty('--accent') deletes the inline value so the element falls back to whatever the cascade gives it.
A custom property name must include the leading -- in both calls. setProperty('accent', ...) silently sets a normal (and meaningless) property; only --accent is a custom property.
Set it globally on :root
A variable set on one element only reaches that element and its descendants. To theme the whole page, set the variable on the root, which in HTML is document.documentElement (the <html> element, the JS handle for the CSS :root selector):
// Every rule using var(--accent) anywhere on the page now updates
document.documentElement.style.setProperty('--accent', '#7c3aed');Because custom properties inherit, anything declared on :root cascades down to every element, and any rule that reads var(--accent) repaints with the new value. Set it on .card instead and only that card (and its children) changes. That scoping is the whole design: put your theme tokens on :root, and per-component overrides on the component.
The power move: change one variable, restyle everything
This is why the API is worth knowing. Drive your styles through a variable in CSS:
:root {
--accent: #e23744;
}
.btn { background: var(--accent); }
a { color: var(--accent); }
.badge { border-color: var(--accent); }Now one JavaScript line re-colors the button, the links, and the badge at once, with no loop over elements and no class toggling:
document.documentElement.style.setProperty('--accent', '#0a7d4b');That single write is the entire theming engine. A color picker is the same line wired to an input:
const picker = document.querySelector('#accent-picker');
picker.addEventListener('input', (e) => {
document.documentElement.style.setProperty('--accent', e.target.value);
});A mouse-follow spotlight is the same idea with two variables updated on pointermove, while the gradient that consumes them lives entirely in CSS:
const te = {
track(el) {
el.addEventListener('pointermove', (e) => {
const r = el.getBoundingClientRect();
el.style.setProperty('--mx', `${e.clientX - r.left}px`);
el.style.setProperty('--my', `${e.clientY - r.top}px`);
});
},
};
te.track(document.querySelector('.spotlight'));.spotlight {
background: radial-gradient(circle at var(--mx) var(--my),
rgba(255, 255, 255, 0.25), transparent 40%);
}Note te here is just a small helper namespace; the browser names addEventListener and getBoundingClientRect, so those stay as-is.
Inline-set value vs the cascade
Worth being precise about which value you read back. getComputedStyle(...).getPropertyValue('--accent') returns the resolved value after the cascade, including a value you set inline. But el.style.getPropertyValue('--accent') reads only the inline value, so it returns empty for a variable that came from a stylesheet rather than from a previous setProperty call. Use the computed read to ask "what is --accent here, from any source"; use the inline read only to ask "did I set this inline myself."
Why this is cheap
Toggling one custom property is far cheaper than walking the DOM and writing many individual styles. The browser does the substitution for you: change --accent once and the engine knows every rule that referenced var(--accent) and repaints them in one pass. The alternative, looping over a querySelectorAll and assigning el.style.background on each node, is more code, more layout thrashing, and slower. Drive the variable; let CSS fan it out.
One caveat on animation: a plain custom property does not interpolate, so transitioning --accent between two colors jumps rather than fades. The fix is to register the property with @property (Baseline since Firefox 128 shipped it in July 2024), which gives the browser a type to interpolate:
@property --accent {
syntax: '<color>';
inherits: true;
initial-value: #e23744;
}With that registration, a setProperty('--accent', ...) change can be animated by a CSS transition on the consuming property. The same @property rule can be registered from JS with CSS.registerProperty() if you prefer to keep it in script.
For doing arithmetic with these values in CSS itself (calc(var(--gap) * 2) and friends), see CSS calc(), min(), max(), and clamp(). To run code the moment a variable-driven transition finishes, see run JavaScript when a CSS transition or animation ends.
FAQ
See also
- CSS calc(), min(), max(), and clamp(): do math with custom properties in CSS itself, like
calc(var(--gap) * 2). - Run JavaScript when a CSS transition or animation ends: fire a callback after a variable-driven transition completes.
- The CSS :has() parent selector: style a parent based on its contents, the selector-side companion to JS-driven theming.
Sources
Authoritative references this article was fact-checked against.
- CSS custom properties (--*): MDN referencedeveloper.mozilla.org
- Window.getComputedStyle(): MDN Web API referencedeveloper.mozilla.org
- CSSStyleDeclaration.setProperty(): MDN Web API referencedeveloper.mozilla.org
- @property at-rule: MDN referencedeveloper.mozilla.org
- @property: next-gen CSS variables with universal browser support (web.dev)web.dev





