Mert Özen Further With Every Line
Frontend CSS3: Flexbox, Grid and Modern CSS — #10

Custom Properties and Theming: var(), @property and Dark Mode with light-dark()

Mert Özen Nov 14, 2026 12 min 2 views
Custom Properties and Theming: var(), @property and Dark Mode with light-dark()

Custom properties live at run time inside the cascade and inheritance; for a dark theme it is enough to define the colour tokens with light-dark() and switch color-scheme. On Bookstore we measure the var() fallback, the invalid at computed-value time trap, typed and animatable variables with @property, an inline script that remembers the user's choice in localStorage without a flash of the wrong theme, and a category colour coming from the CMS.

Version note: This post follows the CSS Custom Properties Level 1, CSS Properties and Values API Level 1 and CSS Color Adjustment specifications and the state of Baseline in November 2026; the examples were run in Chrome 158 and WebKit 27.2 via Playwright 1.65, and Firefox support is taken from web-features 3.46 data.

Guess First

We want to give the price label of Bookstore, the online bookstore used as the shared example across this site's series, an accent colour. A colleague played it safe and wrote a fixed colour first, with the custom property version right below it; the idea is that if the custom property is broken somehow, the browser will fall back to the first line. But someone accidentally put a length into --accent:

/* 1) inheritance, scope and fallback */
.card { --badge-color: rgb(185, 28, 28); }
.badge { color: var(--badge-color, rgb(107, 114, 128)); }

/* 2) invalid at computed-value time: the earlier color declaration is already gone */
.price {
    --accent: 20px;
    color: rgb(37, 99, 235);
    color: var(--accent);
}

Skip .badge for now and look at .price. 20px is not a colour. Does the price show up blue (rgb(37, 99, 235)), or some other colour? Doesn't the browser go back to the previous declaration when it hits an invalid value?

In the earlier posts of this series we used custom properties such as --color-accent and --space, but put off explaining how they work. In this post we look at them inside the cascade and inheritance, give them a type with @property, and add a dark theme to Bookstore with light-dark() that remembers the user's choice and does not show the wrong colours while the page loads.

Custom Properties Live in the Cascade and Inheritance

A custom property is an ordinary CSS property whose name starts with two dashes. It is matched by selectors, goes through the cascade, follows the specificity and layer rules, and is inherited by default. var() reads the value computed on that element. The measuring script opens the lab page and prints the results:

node scripts/10-theme.mjs
--- chromium
badge in card rgb(185, 28, 28), outside rgb(107, 114, 128)
price with --accent: 20px -> color rgb(0, 0, 0)
half way: --stock-level 50%, --plain-level 100%
child of .stock sees --stock-level 0%
--stock-level: 50% then 20px -> 0%
light-dark() token: rgb(255, 255, 255) outside, rgb(17, 24, 39) in the dark band
system light          data-theme=(none) color-scheme light dark: page rgb(255, 255, 255) text rgb(31, 41, 55) | card without cover rgb(254, 243, 199) | toggle aria-pressed=false
system dark           data-theme=(none) color-scheme light dark: page rgb(17, 24, 39) text rgb(229, 231, 235) | card without cover rgb(66, 32, 6) | toggle aria-pressed=true
system dark, clicked     data-theme=light color-scheme light: page rgb(255, 255, 255) text rgb(31, 41, 55) | card without cover rgb(254, 243, 199) | toggle aria-pressed=false
after reload             data-theme=light color-scheme light: page rgb(255, 255, 255) text rgb(31, 41, 55) | card without cover rgb(254, 243, 199) | toggle aria-pressed=false
category dots: Science Fiction rgb(37, 99, 235), Classics rgb(180, 83, 9), Children rgb(107, 114, 128)

The first line is inheritance and fallback. --badge-color is defined only on .card; the badge inside the card inherits it and is red (rgb(185, 28, 28)). For the badge outside the card the property is not defined at all, so the second argument of var(), the grey fallback, was used. That is how you can scope tokens: global tokens on :root, tokens for one component on that component's root element. Everything inside the component sees the latter, everything outside the former.

This is where the difference from Sass variables shows. A Sass variable such as $accent turns into a value at compile time and leaves no trace in the output CSS; the browser never sees it. A custom property lives in the browser, at run time: a media query, a class, an inline style or JavaScript can change it, and the change spreads through the whole subtree. The entire dark theme in this post relies on that. When to use the two together is a topic for the Sass/SCSS series.

The "Invalid at Computed-Value Time" Trap

The second line answers the guess: the price is neither blue nor red but black (rgb(0, 0, 0)), the colour inherited from body. The reason is when a declaration containing var() gets validated. While parsing CSS, the browser cannot know whether color: var(--accent) is valid, because the value of --accent can differ per element. So it treats the declaration as valid and runs the cascade as usual: of the two color declarations in the same rule, the later one wins and the blue one is dropped. The value is substituted only at the computed-value stage; since 20px is not a colour, the declaration becomes invalid at that point. This is called "invalid at computed-value time", and the result is the property's unset value: the parent's colour for color, which is inherited, and the initial value for a property that is not inherited (such as background-color).

So the "fixed value first, custom property version below" fallback does not work. A real fallback is written in one of two ways: with var(--accent, rgb(37, 99, 235)) (which only kicks in if the property is not defined at all), or by giving the property a type with @property. The latter is the topic of the next section.

Typed Custom Properties with @property

An unregistered custom property has no type; to the browser its value is just a sequence of tokens. @property registers a custom property with a syntax, whether it is inherited, and an initial value:

/* 3) a registered property: typed, not inherited, animatable */
@property --stock-level {
    syntax: "<percentage>";
    inherits: false;
    initial-value: 0%;
}
.stock { --plain-level: 0%; transition: --stock-level 1s linear, --plain-level 1s linear; }
.stock.full { --stock-level: 100%; --plain-level: 100%; }
/* 20px is not a percentage: the syntax is checked only at computed-value time,
   so the cascade has already dropped 50% and the initial value is used */
.bad-stock { --stock-level: 50%; --stock-level: 20px; }

The third line of the output shows the difference. When the full class is added to .stock, both custom properties go from 0% to 100%, and both have a one-second transition. The script pauses both transitions half way (500ms). The registered --stock-level is at exactly 50%; the browser knows it is a percentage, so it can compute the values in between. The unregistered --plain-level is already at 100%; a token sequence cannot be interpolated, so no transition was created at all. To animate the colour stop of a gradient or the fill level of a stock bar, you need @property.

The fourth line: the child of .stock sees --stock-level as 0%, because of inherits: false. Not leaking a value that only concerns the element it is defined on (such as an animation's progress) into the subtree is both more correct and cheaper for the browser: when the value changes, it does not need to recompute the styles of the whole subtree.

The fifth line is the earlier trap in its registered form. .bad-stock says 50% first, then 20px, and the result is 0%: the initial value. The Properties and Values API specification states clearly that the registered syntax is checked not at parse time but at the computed-value stage. The cascade has picked 20px and 50% has long been dropped; when the mismatch is noticed later, the property resets to its registered initial value. The difference: with an unregistered property this situation broke the declaration that used var(); with a registered property you at least get a defined value of the right type (the initial-value).

Bookstore's Tokens and light-dark()

For the dark theme I first collected every fixed colour into tokens. A few colours were scattered across component files in the earlier posts of this series (the focus ring, the background of a card without a cover); they all live in base.css now:

/* design tokens: one place for every colour; light-dark() picks a value from the used color-scheme */
:root {
    color-scheme: light dark;
    --color-text: light-dark(#1f2937, #e5e7eb);
    --color-muted: light-dark(#6b7280, #9ca3af);
    --color-surface: light-dark(#ffffff, #111827);
    --color-border: light-dark(#e5e7eb, #374151);
    --color-accent: light-dark(#b91c1c, #f87171);
    --color-focus: light-dark(#1d4ed8, #93c5fd);
    --color-tint: light-dark(#fef3c7, #422006);
    --radius: 0.5rem;
    --space: 1rem;
    font-family: system-ui, sans-serif;
    line-height: 1.5;
    color: var(--color-text);
    background-color: var(--color-surface);
}

/* the user's own choice (theme.js) wins over the system preference */
:root[data-theme="light"] {
    color-scheme: light;
}

:root[data-theme="dark"] {
    color-scheme: dark;
}

h1, h2, h3 {
    line-height: 1.2;
}

/* fluid heading: grows with the viewport between 28px and 40px; the rem part keeps it zoomable */
h1 {
    font-size: clamp(1.75rem, 1.2rem + 2vw, 2.5rem);
}

color-scheme: light dark tells the browser that the page supports both themes; the browser picks the things it draws itself (form controls, scrollbars, the default background) accordingly. light-dark(A, B) returns A if the element's used colour scheme is light and B if it is dark. When the colour scheme is light dark, the used one is decided by the preference in the user's operating system. In the "system light" and "system dark" lines of the output, the page background went from rgb(255, 255, 255) to rgb(17, 24, 39) and the background of the card without a cover from yellow to dark brown; I did not write a single media query.

The last line of the lab shows an important detail:

/* 4) light-dark() inside an unregistered token is resolved where it is used */
:root { color-scheme: light; --token: light-dark(rgb(255, 255, 255), rgb(17, 24, 39)); }
.surface { background-color: var(--token); }
.dark-band { color-scheme: dark; }

The token is defined on :root, and :root is in the light theme. Even so, the background of .surface inside .dark-band is dark (rgb(17, 24, 39)). An unregistered custom property carries light-dark() as it is, without resolving it; the function is resolved on the element where the token is used, against that element's colour scheme. So giving one part of the page color-scheme: dark makes that part dark with all of its tokens. Had you registered these tokens as <color> with @property, the value would be resolved once on :root and this behaviour would be lost. Think twice before registering colour tokens.

I also switched the tokens we wrote for prefers-contrast: more in the previous post to light-dark(); otherwise a user who asked for high contrast would see light borders in the dark theme. Since September 2026, light-dark() also accepts image values (background-image: light-dark(url(…), url(…))); both engines support it.

The User's Choice: data-theme, localStorage and FOUC

Following the system preference is a good default, but not enough: some users want a dark theme during the day, others do not like a site's dark theme. The user's choice should beat the system preference and be remembered on the next visit. In Bookstore the choice is carried by the data-theme attribute on <html>; the two rules in base.css pin color-scheme to a single value based on that attribute, and every token with light-dark() follows on its own. The script driving the button in the footer:

// The user's choice wins over the system preference; without a saved choice the page follows the system.
const root = document.documentElement;
const button = document.querySelector('.theme-toggle');

const isDark = () => root.dataset.theme
    ? root.dataset.theme === 'dark'
    : matchMedia('(prefers-color-scheme: dark)').matches;

const render = () => button.setAttribute('aria-pressed', String(isDark()));

button.addEventListener('click', () => {
    root.dataset.theme = isDark() ? 'light' : 'dark';
    try {
        localStorage.setItem('theme', root.dataset.theme);
    } catch {
        // storage can be blocked (private mode, policies); the choice then lasts until reload
    }
    render();
});

// the system preference can change while the page is open (e.g. automatic dark mode at sunset)
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', render);
render();

The hard part here is the wrong theme showing for a moment while the page loads; this is known as a flash of unstyled content, or FOUC. theme.js is loaded with defer, so it runs after the HTML has been parsed. If you apply the saved choice at that point, the browser may already have painted the page with the system theme, and a user who chose the dark theme sees a white flash on every navigation. The fix is to apply the choice before the CSS, with a small render-blocking inline script in <head>:

<script>
    // runs before the first paint, so a saved theme never flashes the wrong colours
    try {
        const theme = localStorage.getItem('theme');
        if (theme === 'light' || theme === 'dark') document.documentElement.dataset.theme = theme;
    } catch { }
</script>
<link rel="stylesheet" href="css/main.css">
<script src="js/theme.js" defer></script>

This script is render-blocking on purpose: it is a few lines and downloads nothing from the network, so its cost is negligible, and in return the first paint uses the right colours. This site uses the same approach. Access to localStorage is inside try, because some browser settings and private windows can block storage; in that case the page opens with the system theme and does not throw.

The last lines of the output show the flow. The system is dark, the user clicks the button: data-theme=light, color-scheme is now only light, and the page is light. When the page is reloaded, the inline script applies the choice before the CSS and the page comes up directly in the light theme. The button's aria-pressed value reflects the visible theme in every case; a screen reader user hears whether the button is pressed.

Run-Time Values: Category Colour

Some values cannot be known when the CSS is written. In Bookstore, editors pick the colour of each category in the admin panel, and the value comes from the CMS. Generating a class for every colour is not possible; tools like Tailwind that generate classes at build time cannot create a class for a colour that only arrives at run time either. The right tool is an inline custom property:

<li><a class="cat-link" href="catalog.html?category=science-fiction" style="--cat-color: #2563eb">Science Fiction</a></li>
<li><a class="cat-link" href="catalog.html?category=classics" style="--cat-color: #b45309">Classics</a></li>
<li><a class="cat-link" href="catalog.html?category=children">Children</a></li>
/* the category colour comes from the CMS at runtime as an inline --cat-color; CSS only decides where it goes */
.cat-link::before {
    content: "";
    display: inline-block;
    inline-size: 0.625rem;
    block-size: 0.625rem;
    margin-inline-end: 0.5rem;
    border-radius: 50%;
    background-color: var(--cat-color, var(--color-muted));
}

The server only writes the value; CSS decides where the colour goes. In the last line of the output, the dot of Science Fiction is blue and that of Classics orange; Children, which has no colour defined, uses the grey --color-muted, the fallback of var(). To use the same value later in a badge, a border or to derive a hover colour, you do not need to touch the HTML; in the next post we will derive shades from this single colour with color-mix() and relative colour.

In Production

Custom properties have been Baseline and widely available since 2017. color-scheme has been Baseline since 2022, light-dark() since May 2024 (Firefox 120, Safari 17.5), and @property since July 2024 (Firefox 128). In web-features 3.46, light-dark() and @property are not yet marked as widely available; both reach the 30-month threshold in the coming months. If you have to support an older browser, watch one detail: since a custom property accepts any token sequence, a browser that does not know light-dark() still treats the --color-text: light-dark(…) definition as valid. The problem shows up where the token is used: color: var(--color-text) becomes invalid at computed-value time and the text colour falls back to the inherited value. The fix is to redefine the tokens with plain colours for such browsers inside an @supports not (color: light-dark(red, red)) block; those browsers do not get the dark theme, but the page stays readable.

When the theme changes, the whole page is restyled; that is normal and fast. But if you write a frequently changing value (mouse position, scroll ratio) into an inherited custom property on :root, every change recomputes the styles of the whole tree. Write such values either onto the element that uses them, or register them with @property as inherits: false. The details of measuring performance are in the Web Performance series.

Common Pitfalls

First, writing a fixed value above a declaration with var() as a fallback. As we measured, the cascade has already dropped the fixed value; put the fallback in the second argument of var().

Second, writing the dark theme as a separate @media (prefers-color-scheme: dark) block in every component. Applying the user's own choice gets harder, and one component always gets forgotten. Define colours in tokens with light-dark() and let components use only tokens.

Third, applying the saved theme with a script that is deferred or waits for the framework to load. The result is a moment of wrong colours on every page load.

Fourth, registering colour tokens with @property out of habit. light-dark() is then resolved once on :root, and making part of the page dark with color-scheme stops working.

A Small Experiment

★☆☆ Change the color: var(--accent) line of .price in the lab to background-color: var(--accent), and give .price a background-color: rgb(254, 243, 199) before it. Guess the result, then check it by adding a line to node scripts/10-theme.mjs.

★★☆ Make theme.js three-state: "System", "Light", "Dark". When "System" is chosen, remove data-theme and delete the entry in localStorage. Measure all three states in the script while the system is dark.

★★★ Add @property --cover-shade (<percentage>, inherits: false) to the book card and transition the shade over the cover from 0% to 30% on hover. Verify with Playwright's getAnimations() that the value is 15% half way through the transition; try the same with an unregistered property.

In the next post we move on to the colours themselves: from hex to oklch(), deriving a hover shade from the category colour with color-mix() and relative colour, what contrast-color() guarantees and what it does not, and then CSS's math functions.