CSS custom properties vs preprocessor variables

SCSS variables were the standard for years, and they're still useful — but they have a fundamental limitation: they're resolved at compile time and disappear from the output. By the time CSS reaches the browser, $color-brand is already replaced by #7c6fff everywhere it appeared. There's nothing left to work with at runtime.

CSS custom properties work the other way around. They're part of the cascade, they're computed live in the browser, they can be changed with JavaScript, they inherit down the DOM tree, and they can be scoped to a specific element. This makes them qualitatively different — not just syntactic sugar for values you'd otherwise repeat.

SCSS variable

// Compile-time only
$color-brand: #7c6fff;

.button {
  background: $color-brand;
}

// Output in CSS:
// .button { background: #7c6fff; }
// Variable is gone — can't
// be changed, inherited,
// or read from JS.

CSS custom property

/* Lives in the browser */
:root {
  --color-brand: #7c6fff;
}

.button {
  background: var(--color-brand);
}

/* Can be overridden per-scope,
   changed with JS, inherited,
   used in calc(), and more. */

This doesn't mean SCSS is obsolete — the two work well together. Use SCSS for build-time logic (loops, mixins, nesting), and custom properties for runtime theming, component APIs, and any value that needs to change dynamically or be overridden in context.

Primitives vs semantic tokens

The most important architectural decision in a design token system is the split between primitive tokens and semantic tokens. This distinction is what makes a token system actually usable at scale.

Primitive tokens are raw values with no implied usage. They form your palette — they don't say "use this for buttons," they just describe a value:

/* ── Primitives ────────────────────────────────── */
/* Colors */
:root {
  --purple-50: #f5f3ff;
  --purple-100: #ede9fe;
  --purple-400: #a78bfa;
  --purple-500: #7c6fff;
  --purple-600: #6d5ce6;
  --purple-700: #5a47d1;

  --teal-400: #4eecc8;
  --teal-500: #2dd4af;

  --neutral-0: #ffffff;
  --neutral-900: #0a0a0b;
  --neutral-850: #111113;
  --neutral-800: #1a1a1e;

  /* Spacing */
  --size-1: 4px;
  --size-2: 8px;
  --size-4: 16px;
  --size-6: 24px;
  --size-8: 32px;
  --size-12: 48px;

  /* Typography */
  --font-size-sm: 0.875rem;
  --font-size-base: 1rem;
  --font-size-lg: 1.125rem;
  --font-size-xl: 1.5rem;

  /* Radius */
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 12px;
  --radius-full: 9999px;
}

Semantic tokens reference primitive tokens and give them meaning in the context of the UI. Components should only ever use semantic tokens — never primitives directly:

/* ── Semantic tokens ────────────────────────────── */
:root {
  /* Background layers */
  --color-bg: var(--neutral-900);
  --color-surface: var(--neutral-850);
  --color-surface-raised: var(--neutral-800);

  /* Text */
  --color-text: var(--neutral-0);
  --color-text-subtle: rgba(255 255 255 / 0.5);
  --color-text-faint: rgba(255 255 255 / 0.25);

  /* Brand */
  --color-brand: var(--purple-500);
  --color-brand-hover: var(--purple-600);

  /* Accent / status */
  --color-accent: var(--teal-400);
  --color-success: var(--teal-500);
  --color-warning: #f59e0b;
  --color-error: #f87171;

  /* Borders */
  --color-border: rgba(255 255 255 / 0.07);
  --color-border-strong: rgba(255 255 255 / 0.15);

  /* Spacing semantic aliases */
  --space-component-gap: var(--size-4);
  --space-section-padding: var(--size-12);

  /* Component radius */
  --radius-button: var(--radius-md);
  --radius-card: var(--radius-lg);
  --radius-input: var(--radius-md);
}

The rule: components reference semantic tokens. Semantic tokens reference primitives. Primitives are never used directly in component CSS. When a designer changes the brand colour, you update one primitive value, and semantic tokens cascade the change to every component automatically.

Implementing a theme system

The canonical use case for CSS custom properties is a light/dark theme. The approach is simple: define all semantic token values inside a data attribute or class selector, and switch between them with a single DOM attribute change.

/* Light theme (default) */
:root,
[data-theme="light"] {
  --color-bg: #ffffff;
  --color-surface: #f8f8fa;
  --color-text: #111113;
  --color-text-subtle: rgba(17 17 19 / 0.55);
  --color-border: rgba(17 17 19 / 0.08);
  --color-brand: #6d5ce6; /* slightly darker for light bg */
}

/* Dark theme */
[data-theme="dark"] {
  --color-bg: #0a0a0b;
  --color-surface: #111113;
  --color-text: #e8e8f0;
  --color-text-subtle: rgba(232 232 240 / 0.5);
  --color-border: rgba(255 255 255 / 0.07);
  --color-brand: #7c6fff;
}

/* Respect OS preference when no explicit theme is set */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    --color-bg: #0a0a0b;
    --color-surface: #111113;
    /* ... rest of dark tokens */
  }
}

Toggle between themes with a single line of JavaScript:

// Read current theme from localStorage or OS preference
function initTheme() {
  const saved = localStorage.getItem('theme');
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  document.documentElement.dataset.theme = saved ?? (prefersDark ? 'dark' : 'light');
}

// Toggle and persist
function toggleTheme() {
  const current = document.documentElement.dataset.theme;
  const next = current === 'dark' ? 'light' : 'dark';
  document.documentElement.dataset.theme = next;
  localStorage.setItem('theme', next);
}

initTheme(); // call before render to avoid flash

Avoid flash of wrong theme. Call initTheme() in a <script> tag in <head> — not deferred — so the correct data-theme is set before the browser first paints. A deferred script causes a brief wrong-theme flash that users notice.

Component-scoped custom properties

Custom properties inherit down the DOM tree, which means you can override semantic tokens at any point in the hierarchy. This pattern creates a clean component API where internal styles reference local variables, and callers can override just those variables without touching component internals.

/* Component defines its own local API */
.card {
  /* Local token declarations — callers override these */
  --card-bg: var(--color-surface);
  --card-radius: var(--radius-card);
  --card-padding: var(--size-6);
  --card-border: 1px solid var(--color-border);

  background: var(--card-bg);
  border-radius: var(--card-radius);
  padding: var(--card-padding);
  border: var(--card-border);
}

/* Variant: override only what changes */
.card--featured {
  --card-bg: rgba(124 111 255 / 0.06);
  --card-border: 1px solid rgba(124 111 255 / 0.2);
}

.card--compact {
  --card-padding: var(--size-4);
}

/* Caller context can also override — e.g. inside a hero section */
.hero .card {
  --card-bg: transparent;
  --card-border: none;
}

This is a much cleaner alternative to modifier classes with duplicated property lists or !important overrides. The component's internal structure doesn't change — only the tokens it exposes as its public API.

Using custom properties in calc()

Custom properties work seamlessly inside calc(), clamp(), and other CSS functions, which unlocks fluid type scales and spacing systems:

/* Fluid typography using custom properties + clamp */
:root {
  --fluid-min-width: 320;
  --fluid-max-width: 1200;

  /* Text scale */
  --text-sm: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
  --text-base: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  --text-lg: clamp(1.125rem, 1rem + 0.75vw, 1.5rem);
  --text-xl: clamp(1.5rem, 1.2rem + 1.25vw, 2.25rem);
  --text-2xl: clamp(2rem, 1.2rem + 2.5vw, 3.5rem);
}

/* Dynamic spacing with a base unit */
:root {
  --space-unit: 1rem;

  --space-xs: calc(var(--space-unit) * 0.25);  /* 4px */
  --space-sm: calc(var(--space-unit) * 0.5);   /* 8px */
  --space-md: var(--space-unit);               /* 16px */
  --space-lg: calc(var(--space-unit) * 1.5);  /* 24px */
  --space-xl: calc(var(--space-unit) * 2);    /* 32px */
  --space-2xl: calc(var(--space-unit) * 3);   /* 48px */
}

/* If user has a larger base font, all spacing scales up automatically */

Reading and writing from JavaScript

CSS custom properties are part of the computed style — they can be read with getComputedStyle and written with style.setProperty. This is the correct bridge between CSS token system and JavaScript logic:

// Read a custom property from an element
function getCSSVar(name, element = document.documentElement) {
  return getComputedStyle(element)
    .getPropertyValue(name)
    .trim();
}

// Write a custom property to an element
function setCSSVar(name, value, element = document.documentElement) {
  element.style.setProperty(name, value);
}

// Remove a custom property (reverts to cascade value)
function removeCSSVar(name, element = document.documentElement) {
  element.style.removeProperty(name);
}

// Usage examples:
const brandColor = getCSSVar('--color-brand');
// → "#7c6fff"

// Set a section-specific override at runtime
const heroSection = document.getElementById('hero');
setCSSVar('--color-bg', '#1a0a2e', heroSection);

// Animate via custom property (works with CSS transitions)
document.documentElement.style.setProperty('--scroll-progress', `${progress}`);

Scroll-driven animations with custom properties

A powerful pattern: track scroll position as a custom property, then use it in CSS transforms and opacities without any JavaScript animation logic:

// Update --scroll-y as a normalized 0–1 value
window.addEventListener('scroll', () => {
  const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
  document.documentElement.style.setProperty('--scroll-y', progress.toFixed(4));
}, { passive: true });
/* CSS handles all the visual logic */
.parallax-bg {
  transform: translateY(calc(var(--scroll-y, 0) * -80px));
  opacity: calc(1 - var(--scroll-y, 0) * 2);
  will-change: transform;
}

.progress-bar {
  transform: scaleX(var(--scroll-y, 0));
  transform-origin: left;
}

Design tokens in Shopify themes

Shopify's Online Store 2.0 architecture makes excellent use of CSS custom properties for merchant theming. The pattern: theme settings (defined in settings_schema.json) generate CSS custom properties in the layout file, and all component styles reference those properties.

{%- comment -%}
  In layout/theme.liquid — generate token system from merchant settings
{%- endcomment -%}
<style>
  :root {
    /* Brand colours from theme editor */
    --color-primary: {{ settings.color_primary }};
    --color-primary-hover: {{ settings.color_primary | color_darken: 10 }};
    --color-secondary: {{ settings.color_secondary }};
    --color-background: {{ settings.color_background }};
    --color-foreground: {{ settings.color_foreground }};

    /* Contrast-safe text on primary */
    {%- assign contrast = settings.color_primary | color_contrast: settings.color_background -%}
    --color-primary-text: {{ settings.color_background }};

    /* Border radius from setting */
    --border-radius-base: {{ settings.corner_radius }}px;
    --border-radius-button: calc(var(--border-radius-base) * {{ settings.button_radius_multiplier }});

    /* Typography */
    --font-heading-family: {{ settings.type_heading_font.family }}, {{ settings.type_heading_font.fallback_families }};
    --font-heading-weight: {{ settings.type_heading_font.weight }};
    --font-body-family: {{ settings.type_body_font.family }}, {{ settings.type_body_font.fallback_families }};

    /* Spacing density */
    --space-section-padding: {{ settings.section_padding }}px;
  }
</style>

Each section can then extend the root tokens with section-level overrides. This is how Dawn handles section backgrounds, text colours, and custom colour schemes per-section:

{%- comment -%}
  Section-level token overrides — passed from section settings
{%- endcomment -%}
<style>
  #shopify-section-{{ section.id }} {
    --color-background: {{ section.settings.color_scheme.background }};
    --color-foreground: {{ section.settings.color_scheme.text }};
    --color-button: {{ section.settings.color_scheme.button }};
    --color-button-text: {{ section.settings.color_scheme.button_label }};
  }
</style>

<div class="section-{{ section.id }}">
  {%- comment -%}
    All components inside use --color-background, --color-foreground etc.
    The section-level override cascades down automatically.
  {%- endcomment -%}
</div>

This is the right architecture for Shopify themes. Components never hardcode colours. They reference semantic tokens. Section schema settings override those tokens at the section scope. Merchants get full visual control through the theme editor without any component knowing about specific colour values.

Fallback values

The var() function accepts a fallback as the second argument. This is useful for optional component-level tokens that may or may not be set by a parent:

.button {
  /* Use --btn-bg if set by parent context; otherwise use semantic token */
  background: var(--btn-bg, var(--color-brand));
  color: var(--btn-color, var(--color-text));
  border-radius: var(--btn-radius, var(--radius-button));
  padding: var(--btn-padding, var(--space-sm) var(--space-lg));
}

/* Caller context sets only the tokens it wants to change */
.hero .button {
  --btn-bg: transparent;
  --btn-color: var(--color-text);
  border: 1px solid var(--color-border-strong);
}

Fallbacks can be nested: var(--a, var(--b, var(--c, #fallback))). Use this sparingly — deep nesting makes the cascade hard to trace.

The complete token file structure

A practical file structure for a multi-theme design token system:

styles/
├── tokens/
│   ├── primitives.css     ← raw values: color palette, size scale, font families
│   ├── semantic.css       ← meaning-bearing aliases referencing primitives
│   ├── themes/
│   │   ├── light.css      ← [data-theme="light"] overrides
│   │   └── dark.css       ← [data-theme="dark"] overrides
│   └── index.css          ← @import all of the above
├── components/
│   ├── button.css         ← uses semantic tokens only
│   ├── card.css
│   └── ...
└── main.css               ← @import tokens/index.css + components/*