CSS Variables

CSS variables (also called custom properties) are one of those features that start small (“cool, I can reuse a color”) and end big (“wait… I can build an entire design system with this”).

In this tutorial, we’ll go from beginner-friendly basics to real-world patterns: scoping, fallbacks, theming, media queries, naming conventions, and common debugging traps.

What CSS variables are and why they rock

A CSS variable is a value you store under a name like --brand. Then you can reuse it with the var() function.

  • They’re live: if you change the variable, everything using it updates.
  • They’re scoped: you can define variables globally (like :root) or locally (inside a component).
  • They work with most CSS properties (colors, spacing, sizes, transforms, shadows…).
  • They can be combined with calc(), clamp(), etc.

Important note: Throughout this tutorial, we’ll use :host instead of :root to define variables in the Code Playgrounds because they use a shadow root. Everywhere you see :host, just remember it's the equivalent of :root in a regular project.

:host {
  --brand: #7c3aed;
}

.card {
border-color: var(--brand);
}

.card h3 {
color: var(--brand);
} 
:host {
  --brand: #0ea5e9;
}

.card {
  border-color: var(--brand);
}

.card h3 {
  color: var(--brand);
}
  
:host {
  --brand: #16a34a;
}

.card {
  border-color: var(--brand);
}

.card h3 {
  color: var(--brand);
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

body {
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.card {
  width: min(520px, 100%);
  border: 3px solid #111;
  border-radius: 16px;
  padding: 18px;
  background: #fff;
}

.card p {
  margin: 0;
  line-height: 1.5;
  color: #222;
}

.card h3 {
  margin: 0 0 10px 0;
  font-size: 20px;
}
  

One variable, many places

The border and heading color come from --brand. Click the snippets to “swap themes”.

CSS variable declaration and the var() function

CSS variables are declared using a property name that starts with --:

  • Declare: --my-value: 12px;
  • Use: var(--my-value)

Important: you can’t use a variable by writing --my-value directly in a property value. You must wrap it in var().

:host {
  --radius: 22px;
  --pad: 18px;
}

.panel {
border-radius: var(--radius);
padding: var(--pad);
} 
:host {
  --radius: 6px;
  --pad: 24px;
}

.panel {
  border-radius: var(--radius);
  padding: var(--pad);
}
  
:host {
  --radius: 40px;
  --pad: 12px;
}

.panel {
  border-radius: var(--radius);
  padding: var(--pad);
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.panel {
  width: min(560px, 100%);
  border: 3px solid #111;
  background: #f6f6f6;
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.panel h4 {
  margin: 0 0 8px 0;
  font-size: 18px;
}

.panel p {
  margin: 0;
  line-height: 1.5;
}
  

Variables are just reusable values

This panel’s padding and border-radius come from custom properties on :root.

Variable scope: :root vs local (and why it matters)

CSS variables follow normal CSS rules:

  • Variables are available on the element where they’re defined and its descendants.
  • They are inherited by default, meaning children can use them without redefining them.
  • If the same variable is defined multiple times, the closest one wins (the one on the nearest ancestor, considering the cascade).

A common pattern is:

  • Put global tokens on :root (colors, spacing scale, radii).
  • Override per component or per section to create variants.
:host {
  --accent: #7c3aed;
}

.callout {
border-left: 10px solid var(--accent);
}

.callout a {
color: var(--accent);
} 
:host {
  --accent: #7c3aed;
}

.section {
  --accent: #0ea5e9;
}

.callout {
  border-left: 10px solid var(--accent);
}

.callout a {
  color: var(--accent);
}
  
:host {
  --accent: #7c3aed;
}

.section {
  --accent: #16a34a;
}

.callout {
  border-left: 10px solid var(--accent);
}

.callout a {
  color: var(--accent);
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.section {
  width: min(640px, 100%);
  padding: 16px;
  border: 3px solid #111;
  border-radius: 16px;
  background: #fff;
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.callout {
  padding: 14px 16px;
  background: #f6f6f6;
  border-radius: 12px;
}

.callout p {
  margin: 0;
  line-height: 1.5;
}

.callout a {
  font-weight: 700;
  text-decoration: none;
}

.callout a:hover {
  text-decoration: underline;
}
  

The --accent variable can be global, or overridden per section. This link uses it too.

Inheritance is a feature (and a footgun)

If you set a variable on a parent, every child can use it. This is amazing for theming. But if you expect a variable to “stay inside” a component, remember: it flows down the DOM tree.

To keep things predictable, a good habit is to define “component API variables” on the component root, like .button { --button-bg: ... }.

CSS variable naming conventions (that don’t hurt later)

You can name variables anything as long as they start with --. But “anything” is how you end up with --blue2, --blue-final, and --newBlueReallyThisTime.

Three naming styles that scale

  • Design tokens: --color-text, --space-3, --radius-md
  • Component tokens: --card-bg, --button-padding
  • Semantic roles: --danger, --success, --surface

Practical rules of thumb

  • Prefer semantic names over raw colors: --brand beats --purple.
  • Use a consistent separator: kebab-case is the most common (--button-bg).
  • Group by namespace when it helps: --color-*, --space-*, --radius-*.
  • If it’s meant to be overridden from outside, name it like an API: --card-accent.
:host {
  --color-surface: #ffffff;
  --color-text: #111111;
  --color-accent: #7c3aed;

--radius-md: 16px;
--space-md: 16px;
}

.token-card {
background: var(--color-surface);
color: var(--color-text);
border-radius: var(--radius-md);
padding: var(--space-md);
border: 3px solid #111;
}

.token-card strong {
color: var(--color-accent);
} 
:host {
  --color-surface: #0b1220;
  --color-text: #f8fafc;
  --color-accent: #0ea5e9;

  --radius-md: 16px;
  --space-md: 16px;
}

.token-card {
  background: var(--color-surface);
  color: var(--color-text);
  border-radius: var(--radius-md);
  padding: var(--space-md);
  border: 3px solid #111;
}

.token-card strong {
  color: var(--color-accent);
}
  
:host {
  --color-surface: #fff7ed;
  --color-text: #111111;
  --color-accent: #16a34a;

  --radius-md: 28px;
  --space-md: 22px;
}

.token-card {
  background: var(--color-surface);
  color: var(--color-text);
  border-radius: var(--radius-md);
  padding: var(--space-md);
  border: 3px solid #111;
}

.token-card strong {
  color: var(--color-accent);
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.token-card {
  width: min(640px, 100%);
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.token-card p {
  margin: 0;
  line-height: 1.55;
}
  

Token-style variables like --color-text and --space-md make your CSS feel like a system instead of a pile of guesses.

CSS var() fallback patterns

The var() function can take a fallback value:

color: var(--text, #111);

This means: “Use --text if it’s defined. Otherwise use #111.”

When fallbacks actually kick in

  • If the variable is missing (not defined anywhere in scope), fallback is used.
  • If the variable exists but is invalid for that property, the property becomes invalid and usually falls back to its previous computed value. The fallback inside var() can help when the variable is missing, but it can’t magically fix all invalid combinations.

Nested fallbacks for “theme chains”

You can chain variables by using a var() inside the fallback:

color: var(--button-text, var(--text, #111));

This reads as: use --button-text, otherwise --text, otherwise #111.

.card {
  color: var(--card-text, #111);
  background: var(--card-bg, #f6f6f6);
}
  
:host {
  --card-text: #0b1220;
}

.card {
  color: var(--card-text, #111);
  background: var(--card-bg, #f6f6f6);
}
  
:host {
  --text: #0b1220;
}

.card {
  color: var(--card-text, var(--text, #111));
  background: var(--card-bg, #f6f6f6);
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.card {
  width: min(640px, 100%);
  border: 3px solid #111;
  border-radius: 16px;
  padding: 16px;
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.card p {
  margin: 0;
  line-height: 1.55;
}
  

Fallbacks keep your CSS resilient: if a variable is missing, your UI still looks acceptable. Click snippets to see different fallback setups.

CSS variables for colors (and theming)

Colors are the classic “first win” for variables because they’re reused everywhere: text, borders, shadows, backgrounds, focus rings, and more.

Use semantic color tokens

Instead of --blue and --green, try tokens like:

  • --color-text
  • --color-surface
  • --color-muted
  • --color-accent

Then you can swap themes without rewriting component CSS.

:host {
  --color-surface: #ffffff;
  --color-text: #111111;
  --color-muted: #4b5563;
  --color-accent: #7c3aed;
}

.banner {
background: var(--color-surface);
color: var(--color-text);
border-color: var(--color-accent);
}

.banner em {
color: var(--color-muted);
}

.banner a {
color: var(--color-accent);
} 
:host {
  --color-surface: #0b1220;
  --color-text: #f8fafc;
  --color-muted: #cbd5e1;
  --color-accent: #0ea5e9;
}

.banner {
  background: var(--color-surface);
  color: var(--color-text);
  border-color: var(--color-accent);
}

.banner em {
  color: var(--color-muted);
}

.banner a {
  color: var(--color-accent);
}
  
:host {
  --color-surface: #fff7ed;
  --color-text: #111111;
  --color-muted: #7c2d12;
  --color-accent: #16a34a;
}

.banner {
  background: var(--color-surface);
  color: var(--color-text);
  border-color: var(--color-accent);
}

.banner em {
  color: var(--color-muted);
}

.banner a {
  color: var(--color-accent);
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.banner {
  width: min(720px, 100%);
  border: 3px solid #111;
  border-radius: 18px;
  padding: 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.banner h3 {
  margin: 0 0 8px 0;
  font-size: 20px;
}

.banner p {
  margin: 0;
  line-height: 1.55;
}

.banner a {
  font-weight: 700;
  text-decoration: none;
}

.banner a:hover {
  text-decoration: underline;
}
  

  

Color + alpha patterns (RGB/HSL style)

Sometimes you want the same color at different opacities (hover, focus, shadows). A handy trick is to store just the “channels” in a variable, then apply alpha where needed.

Example idea:

  • --accent-rgb: 124 58 237;
  • Use it like: rgba(var(--accent-rgb), 0.2)
:host {
  --accent-rgb: 124 58 237;
}

.pill {
color: rgb(var(--accent-rgb));
border-color: rgb(var(--accent-rgb));
background: rgba(var(--accent-rgb), 0.12);
}

.pill:hover {
background: rgba(var(--accent-rgb), 0.2);
} 
:host {
  --accent-rgb: 14 165 233;
}

.pill {
  color: rgb(var(--accent-rgb));
  border-color: rgb(var(--accent-rgb));
  background: rgba(var(--accent-rgb), 0.12);
}

.pill:hover {
  background: rgba(var(--accent-rgb), 0.2);
}
  
:host {
  --accent-rgb: 22 163 74;
}

.pill {
  color: rgb(var(--accent-rgb));
  border-color: rgb(var(--accent-rgb));
  background: rgba(var(--accent-rgb), 0.12);
}

.pill:hover {
  background: rgba(var(--accent-rgb), 0.2);
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.pill {
  display: inline-block;
  border: 3px solid #111;
  border-radius: 999px;
  padding: 10px 14px;
  font-family: ui-sans-serif, system-ui, sans-serif;
  font-weight: 800;
  text-decoration: none;
  transition: background 200ms ease;
}
  

  Same accent, different alpha

  

Variables for spacing, typography, and design tokens

Design tokens are simply variables that represent your design decisions: spacing steps, font sizes, radii, shadows, etc.

A beginner-friendly spacing scale could look like:

  • --space-1: 4px
  • --space-2: 8px
  • --space-3: 12px
  • --space-4: 16px

Then components can use var(--space-*) instead of random numbers.

:host {
  --space-2: 8px;
  --space-4: 16px;
  --radius: 16px;
}

.stack {
gap: var(--space-4);
}

.stack > div {
padding: var(--space-4);
border-radius: var(--radius);
} 
:host {
  --space-2: 10px;
  --space-4: 22px;
  --radius: 22px;
}

.stack {
  gap: var(--space-4);
}

.stack > div {
  padding: var(--space-4);
  border-radius: var(--radius);
}
  
:host {
  --space-2: 6px;
  --space-4: 12px;
  --radius: 10px;
}

.stack {
  gap: var(--space-4);
}

.stack > div {
  padding: var(--space-4);
  border-radius: var(--radius);
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.stack {
  width: min(720px, 100%);
  display: grid;
}

.stack > div {
  border: 3px solid #111;
  background: #f6f6f6;
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.stack h4 {
  margin: 0 0 8px 0;
  font-size: 18px;
}

.stack p {
  margin: 0;
  line-height: 1.55;
}
  

Card A

Spacing comes from tokens instead of “whatever felt right”.

Card B

Swap a few variables and the whole layout breathes differently.

Mixing variables with calc() and clamp()

CSS variables are strings of CSS values, so they can be used inside functions like calc() and clamp().

calc() patterns

  • Multiply spacing: calc(var(--space) * 2)
  • Combine units: calc(var(--gap) + 1rem)

clamp() patterns for responsive sizing

A popular pattern is storing parts of the clamp in variables to make it easy to tweak:

  • --min, --fluid, --max
:host {
  --min: 1.1rem;
  --fluid: 3.2vw;
  --max: 2.6rem;
}

.title {
  font-size: clamp(var(--min), var(--fluid), var(--max));
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.demo-wrap {
  width: min(780px, 100%);
  border: 3px solid #111;
  border-radius: 18px;
  padding: 18px;
  background: #fff;
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.title {
  margin: 0;
  line-height: 1.1;
}

.demo-wrap p {
  margin: 10px 0 0 0;
  color: #222;
  line-height: 1.5;
}
  

Fluid type powered by variables

Drag the sliders to adjust the pieces of the clamp(). Your variable values update the function live.

Notice something a bit spicy here: we used variable names as “properties” for sliders (name="--min", etc.). That works because custom properties are real CSS properties. Neat, right?

Learn more about calc() in the CSS Calc Interactive Tutorial, and about clamp() in the CSS Clamp Interactive Tutorial.

CSS variables in media queries: the truth

This topic often gets people, so here’s the clean mental model:

  • You can change variables inside a media query.
  • You generally cannot use var() to create the media query condition itself (like @media (min-width: var(--bp))) in a reliable, widely-supported way.

The good part: overriding variables in media queries

A perfect use case: set base tokens, then adjust them for larger screens.

:host {
  --space: 12px;
  --radius: 14px;
}

@media (min-width: 700px) {
:host {
--space: 20px;
--radius: 22px;
}
}

.grid {
gap: var(--space);
}

.tile {
border-radius: var(--radius);
padding: var(--space);
} 
:host {
  --space: 10px;
  --radius: 10px;
}

@media (min-width: 700px) {
  :host {
    --space: 28px;
    --radius: 28px;
  }
}

.grid {
  gap: var(--space);
}

.tile {
  border-radius: var(--radius);
  padding: var(--space);
}
  
:host {
  --space: 16px;
  --radius: 18px;
}

@media (min-width: 700px) {
  :host {
    --space: 16px;
    --radius: 40px;
  }
}

.grid {
  gap: var(--space);
}

.tile {
  border-radius: var(--radius);
  padding: var(--space);
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.grid {
  width: min(820px, 100%);
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.tile {
  border: 3px solid #111;
  background: #f6f6f6;
  min-height: 90px;
  display: grid;
  place-items: center;
  font-weight: 800;
}

@media (max-width: 520px) {
  .grid {
    grid-template-columns: 1fr;
  }
}
  
Tile
Tile
Tile

Learn more about media queries in the CSS Media Queries Interactive Tutorial.

The confusing part: variables inside media query conditions

You might want to do:

@media (min-width: var(--bp-md)) { ... }

In practice, this isn’t something you should rely on for robust CSS. Treat media query conditions as “compile-time-ish” values and custom properties as “runtime-ish” values.

The common solution is simple: keep breakpoints as literal values in media queries, and use variables inside them to change tokens.

Real-world theming: data attributes and prefers-color-scheme

Two very common approaches:

  • Add a theme attribute on a wrapper: <html data-theme="dark">
  • Respect OS theme using @media (prefers-color-scheme: dark)

You can also combine them: “default to OS theme, but allow user override”.

:host {
  --surface: #ffffff;
  --text: #111111;
  --accent: #7c3aed;
}

[data-theme="dark"] {
--surface: #0b1220;
--text: #f8fafc;
--accent: #0ea5e9;
}

.shell {
background: var(--surface);
color: var(--text);
border-color: var(--accent);
}

.shell a {
color: var(--accent);
} 
:host {
  --surface: #ffffff;
  --text: #111111;
  --accent: #7c3aed;
}

@media (prefers-color-scheme: dark) {
  :host {
    --surface: #0b1220;
    --text: #f8fafc;
    --accent: #0ea5e9;
  }
}

.shell {
  background: var(--surface);
  color: var(--text);
  border-color: var(--accent);
}

.shell a {
  color: var(--accent);
}
  
:host {
  --surface: #ffffff;
  --text: #111111;
  --accent: #7c3aed;
}

@media (prefers-color-scheme: dark) {
  :host {
    --surface: #0b1220;
    --text: #f8fafc;
    --accent: #0ea5e9;
  }
}

[data-theme="light"] {
  --surface: #ffffff;
  --text: #111111;
  --accent: #7c3aed;
}

[data-theme="dark"] {
  --surface: #0b1220;
  --text: #f8fafc;
  --accent: #0ea5e9;
}

.shell {
  background: var(--surface);
  color: var(--text);
  border-color: var(--accent);
}

.shell a {
  color: var(--accent);
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.shell {
  width: min(760px, 100%);
  border: 3px solid #111;
  border-radius: 18px;
  padding: 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.shell h3 {
  margin: 0 0 8px 0;
  font-size: 20px;
}

.shell p {
  margin: 0;
  line-height: 1.55;
}

.shell a {
  font-weight: 800;
  text-decoration: none;
}

.shell a:hover {
  text-decoration: underline;
}

.badges {
  margin-top: 12px;
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.badge {
  display: inline-block;
  border: 2px solid #111;
  border-radius: 999px;
  padding: 6px 10px;
  background: #f6f6f6;
  color: #111;
  font-weight: 800;
  font-size: 13px;
}
  

Theme variables

Theme changes are mostly “swap a few variables”, while components keep the same CSS. Accent link

Try snippet 1 Try snippet 2 Try snippet 3

Component “API variables”: making components customizable

A powerful pattern is to define defaults inside the component, but allow overrides from outside by exposing variables.

Think of it as: “Here are the knobs you’re allowed to turn.”

.button {
  --button-bg: #111111;
  --button-text: #ffffff;
  --button-pad: 12px 16px;

background: var(--button-bg);
color: var(--button-text);
padding: var(--button-pad);
} 
.button {
  --button-bg: #111111;
  --button-text: #ffffff;
  --button-pad: 12px 16px;

  background: var(--button-bg);
  color: var(--button-text);
  padding: var(--button-pad);
}

.button.is-fancy {
  --button-bg: #7c3aed;
}
  
.button {
  --button-bg: #111111;
  --button-text: #ffffff;
  --button-pad: 12px 16px;

  background: var(--button-bg);
  color: var(--button-text);
  padding: var(--button-pad);
}

.wrapper {
  --button-bg: #0ea5e9;
  --button-text: #0b1220;
}

.wrapper .button {
  background: var(--button-bg);
  color: var(--button-text);
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.wrapper {
  width: min(760px, 100%);
  border: 3px solid #111;
  border-radius: 18px;
  padding: 18px;
  background: #fff;
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.row {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
  align-items: center;
}

.button {
  border: 0;
  border-radius: 999px;
  font-weight: 900;
  cursor: pointer;
}

.note {
  margin: 14px 0 0 0;
  line-height: 1.55;
}
  

Snippet 3 shows a wrapper overriding “API variables” for all buttons inside it.

Debugging: “Why aren’t my CSS variables working?”

When variables fail, it’s usually one of these:

1) The variable isn’t defined in scope

  • You set --x on an element that is not an ancestor of the element using var(--x).
  • Fix: define it on a parent, or on :root, or pass it down via a wrapper.

2) You forgot var()

  • color: --text; won’t work.
  • Fix: color: var(--text);

3) Typos (and yes, they matter)

  • --Accent and --accent are different.
  • Dashes count: --button-bg is not --buttonbg.

4) The value is invalid for the property

  • If --radius: blue; then border-radius: var(--radius); is invalid.
  • Fix: store a valid value, or store values intended for that role (naming helps).

5) Cascade overrides you didn’t expect

  • A later rule or more specific selector might redefine the variable.
  • Fix: inspect the element in DevTools and look at computed styles for custom properties.
:host {
  --accent: #7c3aed;
}

.good {
--accent: #0ea5e9;
}

.box {
border-color: var(--accent);
} 
:host {
  --accent: #7c3aed;
}

.box {
  border-color: var(--accent, #111111);
}
  
:host {
  --accent: #7c3aed;
}

.box {
  border-color: var(--accdent, #111111);
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.wrap {
  width: min(760px, 100%);
  display: grid;
  gap: 12px;
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.box {
  border: 3px solid #111;
  border-radius: 16px;
  padding: 14px 16px;
  background: #f6f6f6;
}

.good .box {
  border-color: var(--accent);
}
  
In snippet 1, --accent is overridden on the parent, so the child uses it.
In snippet 2/3, the border depends on a variable and a fallback. Typos trigger fallback.

Learn more about the CSS Cascade in the CSS Cascade Interactive Tutorial, and about specificity in the CSS Specificity Interactive Tutorial.

Cheat sheet: CSS variables in one page

  • Declare: --name: value;
  • Use: property: var(--name);
  • Fallback: var(--name, fallback)
  • Scope: defined on an element, available to that element + its descendants
  • Best global home: :root
  • Best practice: semantic names like --color-text and --space-md
  • Media queries: override variables inside them; don’t rely on var() for the query condition
  • Component API: define defaults in the component, allow overrides via custom properties

Best practices that make your future self smile

  • Define global tokens on :root, then override for themes or sections.
  • Use fallbacks for safety when building reusable components.
  • Prefer semantic tokens (--color-surface) over raw tokens (--white).
  • Treat component variables as a public API: name them clearly and keep them stable.
  • When debugging, check: scope, typos, and whether the value is valid for the property.

CSS Variables Conclusion

Once CSS variables click, you’ll start seeing them everywhere: theming, consistency, maintainability, and even fun “live” UI tweaks with zero JavaScript. Welcome to the club.