# Build a CSS variables theme: light, dark and brand colours

[Canonical HTML page](https://gradiently.design/guide/css-custom-properties-theming)

Themes go wrong when colours are named for what they look like instead of what they do. Name the roles, point them at a palette, and light mode, dark mode and a second brand become a few lines each.

## The short version

- A CSS variables theme works best in two layers: palette variables that hold raw colours and semantic variables that describe what each colour is for.
- Components should only ever read semantic variables such as --color-text or --color-surface, never palette values directly.
- Dark mode is then a matter of pointing the semantic variables at different palette values, under a media query or a data attribute.
- The light-dark() function and the color-scheme property let one declaration hold both modes in current browsers.
- A small inline script in the head that sets the saved theme before the page paints prevents the flash of the wrong theme.

A **CSS variables theme** is a set of custom properties, such as `--color-surface` and `--color-text`, that every component reads instead of hard coded colours. To change the theme you change the variables, usually on `:root` or on a `data-theme` attribute, and the whole interface follows. The trick that makes it scale is splitting the variables into two layers: a palette of raw colours, and semantic roles that point at that palette.

## Two layers: palette and roles

Palette variables are named for what they are: `--violet-600`, `--ink-900`. Semantic variables are named for what they do: `--color-accent`, `--color-text-muted`. Components read only the second kind. When dark mode arrives, you repoint the roles and leave every component untouched. This is the same idea as [design tokens](https://gradiently.design/guide/design-tokens), expressed directly in CSS.

| Layer | Example | Who reads it | Changes when |
| --- | --- | --- | --- |
| Palette | `--violet-600: #7c3aed` | Only the role layer | The brand is redesigned |
| Role | `--color-accent: var(--violet-600)` | Every component | The theme or mode changes |
| Component | `--button-bg: var(--color-accent)` | One component | A single component needs a local twist |

The component layer is optional. Use it only where one component genuinely needs to differ from the role.

```css
:root {
  /* Palette: raw values, named for what they are */
  --paper-50: #faf8f5;
  --paper-100: #f1ede6;
  --ink-900: #14121f;
  --ink-700: #3d3a4f;
  --ink-500: #6b6880;
  --violet-600: #6d28d9;
  --violet-300: #c4b5fd;
  --night-950: #0d0b16;
  --night-900: #17142a;
  --night-800: #221e3a;

  /* Roles: what each colour is for (light mode) */
  --color-bg: var(--paper-50);
  --color-surface: var(--paper-100);
  --color-text: var(--ink-900);
  --color-text-muted: var(--ink-500);
  --color-border: color-mix(in oklab, var(--ink-900) 12%, transparent);
  --color-accent: var(--violet-600);
  --color-on-accent: #ffffff;
}

body { background: var(--color-bg); color: var(--color-text); }
.card { background: var(--color-surface); border: 1px solid var(--color-border); }
.button { background: var(--color-accent); color: var(--color-on-accent); }
```

Components never mention violet or ink. That single rule is what makes the theme swappable.

- bg: #faf8f5
- surface: #f1ede6
- text: #14121f
- text-muted: #6b6880
- accent: #6d28d9

The light theme's roles. A warm paper ground rather than pure white makes the violet accent feel calmer.

### Naming roles that survive a redesign

A role name should still be true after the colours change. `--light-grey` stops being light in dark mode, and `--blue` lies the day the brand turns green. Name the job and the pairing instead, so whoever reads the stylesheet next knows which colour goes on which.

### Names that break

- `--blue`, `--purple-button`
- `--light-grey-bg`
- `--white-text`
- `--dark-border`

### Names that last

- `--color-accent`, `--color-on-accent`
- `--color-surface`
- `--color-text`
- `--color-border`

## Adding dark mode with CSS variables

Dark mode repoints the roles. Respect the operating system setting by default, and let a `data-theme` attribute override it when the reader chooses. Set `color-scheme` too, so scrollbars, form controls and the default canvas match.

```css
:root { color-scheme: light; }

/* Follow the system unless the reader picked light */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --color-bg: var(--night-950);
    --color-surface: var(--night-900);
    --color-text: #f2f0fa;
    --color-text-muted: #a5a1bd;
    --color-border: color-mix(in oklab, #ffffff 12%, transparent);
    --color-accent: var(--violet-300);
    --color-on-accent: var(--ink-900);
  }
}

/* The reader picked dark explicitly */
:root[data-theme="dark"] {
  color-scheme: dark;
  --color-bg: var(--night-950);
  --color-surface: var(--night-900);
  --color-text: #f2f0fa;
  --color-text-muted: #a5a1bd;
  --color-border: color-mix(in oklab, #ffffff 12%, transparent);
  --color-accent: var(--violet-300);
  --color-on-accent: var(--ink-900);
}
```

The dark values appear twice: once for the system preference and once for an explicit choice. A preprocessor mixin or `light-dark()` removes the repetition.

- bg: #0d0b16
- surface: #17142a
- text: #f2f0fa
- text-muted: #a5a1bd
- accent: #c4b5fd

The dark theme's roles. The accent lifts to a lighter violet, because the light mode violet is too dark to read on a near black ground.

Dark mode is not inverted light mode. Surfaces get lighter as they rise, accents lighten, and shadows give way to borders. [Dark mode design](https://gradiently.design/guide/dark-mode-design) and [dark mode gradients](https://gradiently.design/guide/dark-mode-gradients) cover the visual side.

### The light-dark() shortcut

Current browsers support `light-dark()`, which takes a light value and a dark value and picks one based on the element's `color-scheme`. Set `color-scheme: light dark` on the root to follow the system, or force one with the attribute, and each role becomes a single line: `--color-bg: light-dark(var(--paper-50), var(--night-950))`. Keep the longer form if you must support browsers from before 2024.

## A theme toggle that doesn't flash

If the saved choice is applied after the page renders, readers who picked dark see a white flash on every load. Set the attribute in a tiny inline script in the `head`, before any stylesheet paints the body.

```html
<head>
  <script>
    (function () {
      var saved = localStorage.getItem("theme");
      if (saved === "light" || saved === "dark") {
        document.documentElement.dataset.theme = saved;
      }
    })();
  </script>
  <link rel="stylesheet" href="/styles.css">
</head>
```

Runs before first paint. The toggle button then only needs to set `dataset.theme` and save the value to `localStorage`.

1. **Offer three choices** Light, dark and system. Removing the attribute returns control to `prefers-color-scheme`.
2. **Save only explicit choices** Store `light` or `dark`; delete the key for system, so a later change in the operating system is respected.
3. **Avoid transitions on load** If colours transition, enable the transition only after the first paint, or the page animates from light to dark as it loads.

## Brand themes beyond light and dark

The same two layers handle several brands in one codebase. Each brand provides its own palette and role values under a `data-brand` attribute, and modes still work on top. With `color-mix()` you can derive hover states and tints from one accent per brand, as shown in [color-mix() in CSS](https://gradiently.design/guide/css-color-mix).

```css
[data-brand="harbour"] {
  --color-accent: #0f766e;
  --gradient-hero: linear-gradient(150deg, #042f2e 0%, #0f766e 55%, #5eead4 100%);
}

[data-brand="ember"] {
  --color-accent: #c2410c;
  --gradient-hero: linear-gradient(150deg, #431407 0%, #c2410c 55%, #fdba74 100%);
}

.button:hover { background: color-mix(in oklab, var(--color-accent), black 12%); }
.hero { background: var(--gradient-hero); }
```

Gradients can be tokens too. Store the whole value in one variable and the hero changes with the brand.

- Harbour hero: `linear-gradient(150deg, #042f2e 0%, #0f766e 55%, #5eead4 100%)`
- Ember hero: `linear-gradient(150deg, #431407 0%, #c2410c 55%, #fdba74 100%)`

The two hero tokens from the code above. Same structure, same angle, different brand.

Because custom properties inherit, a theme does not have to cover the whole page. Put `data-theme="dark"` on one section, such as a promotional band or a footer, and everything inside it reads the dark roles while the rest of the page stays light. Write the dark selector as `[data-theme="dark"]` rather than `:root[data-theme="dark"]` if you want that to work, and set `color-scheme` on the section as well.

> **Gradients don't transition on their own** Changing a gradient variable swaps the background instantly. To animate between two gradients, register the colour stops with `@property`, explained in [CSS @property](https://gradiently.design/guide/css-property-animation).

## Keep the web and your designs on one palette

A theme in CSS is only half of a brand. The same colours should appear in your social posts, decks and emails. Gradiently's brand kit holds your palettes, logos and heading and body fonts, and the Designer applies it when it lays out a design, so the post you make on Monday uses the same accent as your site. If you are still choosing those colours, start with [how to choose brand colours](https://gradiently.design/guide/how-to-choose-brand-colors).

## FAQ

### How do I make a theme with CSS variables?

Define palette variables with raw colours, then semantic variables such as `--color-bg` that point at them. Components read only the semantic ones, and themes repoint them.

### How do I add dark mode with CSS variables?

Override the semantic variables inside `@media (prefers-color-scheme: dark)` and under a `[data-theme="dark"]` selector for readers who choose it.

### How do I stop the theme flashing on page load?

Read the saved theme in a small inline script in the `head` and set the attribute before the stylesheet paints the page.

### What does light-dark() do in CSS?

It returns its first value in light mode and its second in dark mode, based on the element's `color-scheme`. It works in current major browsers.

### Can I store a gradient in a CSS variable?

Yes. Store the whole gradient as the value and use it in `background`. To animate between gradients, register the stops with `@property`.
