TechEarl

Get and Set CSS Variables (Custom Properties) with JavaScript

Read and write CSS custom properties from JavaScript: getComputedStyle().getPropertyValue() to read (mind the whitespace), style.setProperty() to write, and the :root trick that re-themes a whole page from one variable.

Ishan Karunaratne⏱️ 7 min readUpdated
Share thisCopied
Read and write CSS custom properties from JavaScript with getComputedStyle().getPropertyValue() and style.setProperty(), set them globally on :root, and re-theme a page from one variable.

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:

javascript
// 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:

javascript
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:

javascript
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):

javascript
// 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:

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:

javascript
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:

javascript
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:

javascript
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'));
css
.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:

css
@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

Sources

Authoritative references this article was fact-checked against.

TagsCSS variablescustom propertiesJavaScriptgetComputedStylesetPropertytheming@property

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to Merge Two Arrays in JavaScript

Merge two arrays in JavaScript three ways: copy-merge with spread [...a, ...b], merge in place with a.push(...b), or copy with a.concat(b). Which to pick by mutation, memory, and the spread arg-count limit that bites on very large arrays.