Market
Pricing
Sign inStart
Field GuideCSS and web
CSS and web

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

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.

GradientlyVerified Gradiently account·October 1, 2026·5 min read
Cover: Modal Compass · GR·5TEZ·Q9

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.
On this page
Two layers: palette and rolesAdding dark mode with CSS variablesA theme toggle that doesn't flashBrand themes beyond light and darkKeep the web and your designs on one palette

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, expressed directly in CSS.

Palette--violet-600: #7c3aed

Who reads it

Only the role layer

Changes when

The brand is redesigned
Role--color-accent: var(--violet-600)

Who reads it

Every component

Changes when

The theme or mode changes
Component

Example

--button-bg: var(--color-accent)

Who reads it

One component

Changes when

A single component needs a local twist
LayerExampleWho reads itChanges when
Palette--violet-600: #7c3aedOnly the role layerThe brand is redesigned
Role--color-accent: var(--violet-600)Every componentThe theme or mode changes
Component--button-bg: var(--color-accent)One componentA 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.
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.
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 and 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. 1

    Offer three choices

    Light, dark and system. Removing the attribute returns control to prefers-color-scheme.

  2. 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. 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.

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.
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.

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.

Ask the Designer

Describe what you need and Gradiently’s AI makes it, on your Mark.

Try the Designer

Questions people ask

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.

Share this guide

Written by GradientlyVerified Gradiently account

The team behind Gradiently, a design tool built around Marks: living gradients that make everything you design look like yours.

See our profile

On this page

Two layers: palette and rolesAdding dark mode with CSS variablesA theme toggle that doesn't flashBrand themes beyond light and darkKeep the web and your designs on one palette

Share this guide

Next in CSS and web

CSS gradient generator: write gradients by hand or with tools

Keep reading

All CSS and web guides
CSS and web

Design tokens: one source of truth for colour and type

5 min read

A brand colour that lives in forty files will drift. A token lives in one place and every platform reads from it. Here is how to structure design tokens, name them so they last, and ship them to code and design tools.

Backgrounds

Dark mode design: backgrounds, colour and contrast

5 min read

Dark mode is not light mode inverted. Surfaces, text, accents and gradients all need their own values, and the rules are different from the ones you know.

CSS and web

color-mix() in CSS: blending colours in the browser

5 min read

color-mix() lets the browser do the colour maths you used to do in a preprocessor or by eye. Pick the right colour space and one brand colour can produce a whole palette of tints, shades and translucent versions.

Find the look that’s only yours.

Every Mark is a living background with one owner. Try an unclaimed Mark, and claim yours when it feels right.

Start designing

Check a certificate

Type a certificate number or a Mark’s code to see who owns it.

Works with ChatGPT and Claude

Ask your AI for a design. It makes it in your Mark, ready to edit.

ChatGPTClaude
How to connect

Product

  • Market
  • Explore
  • Pricing
  • Certificates

Learn

  • Field Guide
  • Help centre
  • What’s new

Developers

  • Overview
  • ChatGPT and Claude
  • API reference
  • API keys

Company

  • About
  • Contact us
  • Sign in

Legal

  • Terms
  • Privacy
  • Refunds
  • Mark protection
  • Cookies
  • All policies
Move through the letters. Each one wears a real Mark.Touch a letter to see its Mark.One name. Ten Marks inside it.
© 2026 Gradiently