CSS variables#

Sass resolves at build time. Tailwind ships utilities. Custom properties are the third option, and the only one whose values can still change once the page is live — which makes them the mechanism for themes, densities and anything else a user picks.

Runnable version of this page: examples/with-css-variables.

Declare defaults, read them everywhere#

/* src/styles.css */
:root {
  --brand: #0062ff;
  --pad: 1.25rem;
  --radius: 1rem;
}

.cta {
  padding: 0.55rem 1.1rem;
  border-radius: var(--radius);
  background: var(--brand);
  color: #fff;
}

.preview {
  padding: var(--pad);
  border-left: 4px solid var(--brand);
  border-radius: var(--radius);
}

Not one of those rules names a colour. They name a variable, which is what lets something else decide the value later.

Let state write the values#

An island renders the properties onto a wrapper, and the cascade repaints everything inside it:

import { component, enums, intent, schema } from 'janux';

const BRANDS = { ocean: '#0062ff', ember: '#d1442f' };

export const ThemeLab = component({
  name: 'theme-lab',
  description: 'Rethemes the page by writing CSS custom properties from state.',
  state: schema({ brand: enums(['ocean', 'ember']).default('ocean') }),
  intents: {
    setBrand: intent({
      description: 'Change the accent colour.',
      input: schema({ brand: enums(['ocean', 'ember']) }),
      run: ({ state, input }) => (state.brand = input.brand),
    }),
  },
  view: ({ state, intents }) => (
    <section style={{ '--brand': BRANDS[state.brand] }}>
      <button class="cta" onClick={intents.setBrand.with({ brand: 'ember' })}>
        Primary action
      </button>
    </section>
  ),
});

Custom properties are valid keys in the typed style object, so --brand is checked like any other property. See inline and JSX styles.

Why this beats generating classes#

Four accents × two densities × two radii is sixteen hand-written variant classes, and every new axis multiplies the last. As custom properties it stays three declarations that happen to hold different values — and the stylesheet never grows.

It also means no new CSS is loaded when the theme changes. The document's stylesheet count is identical before and after; only computed values differ. That is the difference between retheming and re-downloading.

Server-rendered from the first byte#

The properties are written into the markup during SSR, so the themed page arrives already themed:

<section style="--brand:#0062ff">…</section>

There is no flash of default styling waiting for JavaScript, because the island's state was already resolved on the server. See SSR and resumability.

Persisting the choice#

A theme that resets on reload is not a theme. The picked value is ordinary island state, so it persists the same way any other state does — with a store() and persist: 'local', or by lifting it into typed URL state when the theme should survive being shared as a link.

Custom properties also compose with the OS preference rather than competing with it — see dark mode for how the two fit together.