CSS variables: define once on :root, use everywhere with var()

A CSS variable is a property whose name starts with two dashes. Define it once, read it anywhere below with var(), and one change restyles every place that uses it.

CSS variables (the specification calls them custom properties) are properties whose name starts with --. Declare them once on :root, read them anywhere with var(--name), and changing one value updates every rule that uses it.

:root {
  --brand: #2563eb;
  --radius: 10px;
}
.button {
  background: var(--brand);
  border-radius: var(--radius);
}

Try it. The pickers below change three variables on :root, and the whole sample restyles as you drag. The box underneath prints the current :root block.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSS variables theme editor</title>
<style>
  /* 1. Define the variables once, on :root */
  :root {
    --brand: #2563eb;
    --radius: 10px;
    --space: 16px;
  }

  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .wrap { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
  @media (max-width: 560px) { .wrap { grid-template-columns: 1fr; } }

  .controls label { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; font-size: 14px; }
  .controls input[type=range] { flex: 1; max-width: 140px; }
  pre { margin: 6px 0 0; padding: 10px 12px; background: #1d2330; color: #e5e7eb; border-radius: 8px; font-size: 13px; line-height: 1.5; }

  /* 2. Use them with var(), everywhere */
  .card {
    background: #fff;
    padding: var(--space);
    border-radius: var(--radius);
    border-top: 4px solid var(--brand);
    box-shadow: 0 4px 14px rgba(0, 0, 0, .08);
  }
  .card h3 { margin: 0 0 calc(var(--space) / 2); color: var(--brand); }
  .card p { margin: 0 0 var(--space); font-size: 14px; line-height: 1.5; }
  .card input {
    width: 100%; box-sizing: border-box; margin-bottom: var(--space);
    padding: calc(var(--space) / 2); border: 1px solid #d5d9e0; border-radius: var(--radius); font: inherit;
  }
  .btn {
    padding: calc(var(--space) / 2) var(--space);
    border: 0; border-radius: var(--radius);
    background: var(--brand); color: #fff; font: 600 14px system-ui, sans-serif; cursor: pointer;
  }
  .btn.ghost { background: transparent; color: var(--brand); box-shadow: inset 0 0 0 2px var(--brand); }
</style>
</head>
<body>
<div class="wrap">
  <div class="controls">
    <label>--brand <input type="color" id="brand" value="#2563eb"></label>
    <label>--radius <input type="range" id="radius" min="0" max="24" value="10"></label>
    <label>--space <input type="range" id="space" min="8" max="32" value="16"></label>
    <pre id="out"></pre>
  </div>

  <div class="card">
    <h3>Newsletter</h3>
    <p>Every colour, corner and gap here reads from three variables on :root.</p>
    <input placeholder="you@example.com">
    <button class="btn">Subscribe</button>
    <button class="btn ghost">Later</button>
  </div>
</div>

<script>
  const root = document.documentElement;
  const units = { brand: '', radius: 'px', space: 'px' };

  function show() {
    // read the current values back from :root
    const css = getComputedStyle(root);
    document.getElementById('out').textContent =
      ':root {\n' +
      Object.keys(units).map((k) => '  --' + k + ': ' + css.getPropertyValue('--' + k).trim() + ';').join('\n') +
      '\n}';
  }

  Object.keys(units).forEach((k) => {
    document.getElementById(k).addEventListener('input', (e) => {
      // 3. Change a variable from JavaScript: everything using it updates
      root.style.setProperty('--' + k, e.target.value + units[k]);
      show();
    });
  });

  show();
</script>
</body>
</html>
Three variables on :root drive every colour, corner and gap. Edit the code and the example reruns.

Defining variables on :root and reading them with var()

A variable is declared like any other property, inside a rule. The name must start with two dashes, and it is case-sensitive: --Brand and --brand are two different variables.

:root matches the <html> element. Custom properties inherit, so a variable declared there reaches every element on the page. That is why most stylesheets keep their shared values in one :root block at the top.

To use a value, write var(--name) where a value would go. It works as a whole value, as one part of a shorthand, or inside calc():

.card {
  padding: var(--space);
  border: 1px solid var(--brand);
  margin-bottom: calc(var(--space) * 2);
}

Fallback values in var()

var() takes an optional second argument, used when the variable is not defined at all:

color: var(--text, #1d2330);
font-family: var(--font, Georgia, serif);
padding: var(--pad, var(--space, 12px));

Everything after the first comma is the fallback, commas included, so Georgia, serif is one fallback. A fallback can itself be another var().

The fallback does not step in when the variable exists but holds a wrong value. That case behaves differently, and it is the one that surprises people.

Scope: overriding a variable in a component or a media query

A variable follows the same inheritance as color: an element sees the nearest declaration above it in the tree.

Redefine the variable on a class, and that element and everything inside it get the new value, while the rest of the page keeps the old one.

:root sets the value, one card overrides it, and each card's contents inherit from their own card.
:root sets the value, one card overrides it, and each card's contents inherit from their own card.

The same card appears three times below. The middle one sets --accent on itself. The button sets --accent on the row that holds all three, and only the two cards without their own value follow it.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSS variable scope</title>
<style>
  :root {
    --accent: #2563eb;
    --pad: 20px;
  }

  /* Narrow screens: one override, every card gets smaller padding */
  @media (max-width: 560px) {
    :root { --pad: 10px; }
  }

  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
  @media (max-width: 560px) { .row { grid-template-columns: 1fr; } }

  .card {
    background: #fff;
    padding: var(--pad);
    border-radius: 10px;
    border-left: 5px solid var(--accent);
  }
  .card b { color: var(--accent); }
  .card code { display: block; white-space: pre; margin-top: 6px; font-size: 12.5px; color: #4b5563; }

  /* Override inside one card only. Its children inherit the new value. */
  .card.warning { --accent: #d9480f; }

  button { margin-top: 14px; padding: 8px 14px; border-radius: 8px; border: 1px solid #c9ced6; background: #fff; font: inherit; cursor: pointer; }
</style>
</head>
<body>
<div class="row" id="row">
  <div class="card"><b>Default</b><code></code></div>
  <div class="card warning"><b>.warning</b><code></code></div>
  <div class="card"><b>Default</b><code></code></div>
</div>
<button id="toggle">Set --accent on the row</button>

<script>
  const row = document.getElementById('row');
  const cards = row.querySelectorAll('.card');

  function show() {
    // each card reports the value it actually sees
    cards.forEach((c) => {
      const s = getComputedStyle(c);
      c.querySelector('code').textContent =
        '--accent: ' + s.getPropertyValue('--accent').trim() + '\n--pad: ' + s.getPropertyValue('--pad').trim();
    });
  }

  document.getElementById('toggle').addEventListener('click', (e) => {
    const on = row.style.getPropertyValue('--accent');
    if (on) { row.style.removeProperty('--accent'); e.target.textContent = 'Set --accent on the row'; }
    else { row.style.setProperty('--accent', '#0f9d58'); e.target.textContent = 'Remove it again'; }
    show();
  });

  addEventListener('resize', show);
  show();
</script>
</body>
</html>
Each card prints the value it actually sees. On a narrow screen the media query changes --pad for all three.
.card.warning { --accent: #d9480f; }

@media (max-width: 560px) {
  :root { --pad: 10px; }
}

The media query line is the common pattern for responsive spacing: the condition stays a plain number, and only the variable's value changes inside it. Media queries covers the conditions themselves.

Changing CSS variables with JavaScript

Variables are live. Set one from a script and every rule that reads it updates, with no class toggling and no loop over elements.

const root = document.documentElement;

// write
root.style.setProperty('--brand', '#e11d48');

// read the value an element actually sees
getComputedStyle(root).getPropertyValue('--brand').trim();

// remove the inline value, so the stylesheet value applies again
root.style.removeProperty('--brand');

Two details matter. root.style.brand and root.style['--brand'] do not set a custom property; use setProperty. And a value set this way lives in the element's inline style, so it wins over the :root rule in your stylesheet until you remove it.

To read what a nested element sees, call getComputedStyle on that element, not on :root. The scoping example above does exactly that for each card.

Theming: light, dark and more with data-theme

Name the roles, not the colours: --bg, --surface, --text, --brand. Components only ever use those names. A theme is then one rule that redefines the roles, selected by an attribute on <html>.

:root { --bg: #f4f5f7; --text: #1d2330; --brand: #2563eb; }
[data-theme="dark"] { --bg: #16181d; --text: #e8eaed; --brand: #7aa2ff; }
document.documentElement.dataset.theme = 'dark';
Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Design tokens with CSS variables</title>
<style>
  /* Tokens: the default theme */
  :root {
    --bg: #f4f5f7;  --surface: #ffffff;  --text: #1d2330;  --muted: #5b6472;  --border: #e1e4ea;
    --brand: #2563eb;  --on-brand: #ffffff;
    --ok: #0f7b3f;  --ok-bg: #e3f6ea;
    --warn: #9a5b00;  --warn-bg: #fff3d6;
    --danger: #b42318;  --danger-bg: #fde8e6;
    --radius: 10px;  --space: 14px;
  }
  /* Each theme only redefines the tokens that change */
  [data-theme="dark"] {
    --bg: #16181d;  --surface: #20242b;  --text: #e8eaed;  --muted: #a0a7b3;  --border: #343a44;
    --brand: #7aa2ff;  --on-brand: #10131a;
    --ok: #6fdc9b;  --ok-bg: #173325;
    --warn: #ffcf66;  --warn-bg: #3a2e12;
    --danger: #ff8f84;  --danger-bg: #3d1c1a;
  }
  [data-theme="forest"] {
    --bg: #eef3ec;  --surface: #fbfdf9;  --text: #1f2a1c;  --muted: #56644f;  --border: #d3dfcd;
    --brand: #2f6b3a;  --on-brand: #ffffff;
    --radius: 2px;  --space: 16px;
  }

  /* Components: only var(), no colour written twice */
  body { margin: 0; padding: var(--space); font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); transition: background .2s, color .2s; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: var(--space); }
  .btn {
    padding: 8px 14px; border-radius: var(--radius); font: 600 14px system-ui, sans-serif; cursor: pointer;
    border: 1px solid var(--border); background: var(--surface); color: var(--text);
  }
  .btn.primary { background: var(--brand); border-color: var(--brand); color: var(--on-brand); }
  .btn[aria-pressed="true"] { box-shadow: inset 0 0 0 2px var(--brand); }

  .grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space); }
  @media (max-width: 520px) { .grid { grid-template-columns: 1fr; } }
  .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space); }
  .card h3 { margin: 0 0 6px; font-size: 16px; }
  .card p { margin: 0 0 12px; color: var(--muted); font-size: 14px; line-height: 1.5; }

  .alert { margin-top: 10px; padding: 10px 12px; border-radius: var(--radius); font-size: 14px; border-left: 4px solid currentColor; }
  .alert.ok { color: var(--ok); background: var(--ok-bg); }
  .alert.warn { color: var(--warn); background: var(--warn-bg); }
  .alert.danger { color: var(--danger); background: var(--danger-bg); }
</style>
</head>
<body>
<div class="bar" id="themes">
  <button class="btn" data-set="light" aria-pressed="true">Light</button>
  <button class="btn" data-set="dark" aria-pressed="false">Dark</button>
  <button class="btn" data-set="forest" aria-pressed="false">Forest</button>
</div>

<div class="grid">
  <div class="card">
    <h3>Starter plan</h3>
    <p>Buttons, cards and alerts share one set of tokens.</p>
    <button class="btn primary">Choose plan</button>
    <button class="btn">Details</button>
  </div>
  <div class="card">
    <h3>Status</h3>
    <div class="alert ok">Payment received.</div>
    <div class="alert warn">Card expires next month.</div>
    <div class="alert danger">Two invoices are overdue.</div>
  </div>
</div>

<script>
  // Switching theme = changing one attribute. CSS does the rest.
  document.getElementById('themes').addEventListener('click', (e) => {
    const name = e.target.dataset.set;
    if (!name) return;
    document.documentElement.dataset.theme = name;
    document.querySelectorAll('[data-set]').forEach((b) =>
      b.setAttribute('aria-pressed', b.dataset.set === name));
  });
</script>
</body>
</html>
Buttons, cards and alerts read the same tokens. One attribute on the html element switches between three themes.

A theme only needs to list the tokens it changes. The forest theme above changes colours and also --radius, so the corners go square without touching any component rule.

This article does not repeat how to follow the reader's system setting. Dark mode CSS covers prefers-color-scheme and the details of dark palettes, and CSS colour variables shows how to replace the palette of a generated page.

Invalid values: why a variable is silently ignored

The browser does not check a variable's value when it reads the stylesheet, because --space: 20 could be meant for anything. It checks only when a property uses it. If the result is not valid for that property, the property becomes invalid at computed-value time.

A number with no unit is not a valid padding. The property falls back to its initial value, not to the line above.
A number with no unit is not a valid padding. The property falls back to its initial value, not to the line above.

In that case the property acts as if it were unset: an inherited property such as color takes the parent's value, and a non-inherited one such as padding takes its initial value.

It does not go back to an earlier declaration of the same property, because the cascade already discarded that one.

The fix is to keep the unit inside the variable (--space: 20px), or add it with calc(var(--space) * 1px). Writing var(--space)px does not glue the two together.

Where var() does not work

var() substitutes a value inside a property declaration. Anything outside a declaration is off limits: media query conditions, selectors, and property names.

var() goes inside property values. Media query conditions, selectors and property names cannot use it.
var() goes inside property values. Media query conditions, selectors and property names cannot use it.
Where Works? Use instead
A property value Yes -
Inside calc() Yes -
A media query condition No Write the number, change variables inside the query
A selector or property name No A class or attribute selector
Joined to a unit, as in var(--n)px No calc(var(--n) * 1px)

When it does not work

What you see Cause Fix
The rule has no effect The name lacks --, or the value lacks var() --brand: red to define, var(--brand) to use
The fallback shows instead of your value Typo, or a case difference such as --Brand Copy the exact name from the definition
The property resets to default The value is not valid for that property Check units; inspect the computed value in DevTools
The media query never matches var() used in the condition Write the number in the condition
One element ignores the variable It is defined on a sibling, not an ancestor Move it to :root or a shared parent
A script change does nothing Set with style.name instead of setProperty el.style.setProperty('--name', value)

For background colours in particular, CSS background-color lists the formats a variable can hold.

A theme is easier to judge by clicking than by reading hex codes. A screenshot shows one theme, and an .html attachment may open as plain code on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can switch themes and drag the sliders themselves. If you change the code later, the same link shows the new version.

Questions people ask

What is the difference between CSS variables and CSS custom properties?

None. "Custom properties" is the name in the specification, and "CSS variables" is what most people call them. Both mean a property whose name starts with two dashes, read back with var().

Why define CSS variables on :root?

:root matches the html element, which is an ancestor of everything on the page. Custom properties inherit, so a variable declared there is visible to every element. Declaring it on a smaller element limits it to that element and its children.

Why is my CSS variable not working?

The usual causes are a missing -- in the name, a name that differs in upper and lower case, a value that is not valid for the property (such as a number with no unit), or a variable defined on an element that is not an ancestor of the one using it.

Can I use a CSS variable in a media query?

Not in the condition. @media (max-width: var(--bp)) does not work, because var() only works inside property values. You can change a variable's value inside a media query, which is the common way to adjust spacing on small screens.

How do I change a CSS variable with JavaScript?

Call element.style.setProperty('--name', value) on the element that should hold it, usually document.documentElement for :root. Read the current value with getComputedStyle(element).getPropertyValue('--name').

Keep reading