# Theming

> Roles, not hex codes: how to rebrand without touching a single component, and why a theme is assembled rather than typed out.

- Page: https://granularity.tech/docs/theming
- Last reviewed: 2026-10-16

A design system's promise reads like this: **rebranding is a change of token
values, and component sources are never opened**. This page is about how that
works and where the rakes are laid out.

## The shortest thing that works

A couple of colours of a built-in theme can be changed without creating a single
file:

```js title="granum.config.mjs"
defineGranumConfig({
  engine: windEngine(),
  providers: ['@feugene/granularity'],
  themes: {
    names: ['light', 'dark'],
    tokenOverrides: {
      dark: { 'gr-primary': '#4fd1e0' },
    },
  },
})
```

Rebuild. The value lands after the package's dark theme file, under its selector,
and overrides it. The role's derived `-hover` and `-active` follow it — they are
computed by a `color-mix` formula. The tone's other roles — `-solid` with its
states, `-fg`, `-light`, `-text` — are values of the theme in their own right:
solid buttons are painted by `-solid`, links by `-text`. When you repaint a
tone, derive the whole family with `tone()`.

## What a theme is

A theme is **a set of semantic roles** (`--gr-bg`, `--gr-primary`,
`--gr-danger-text`, …) declared on a single selector. Nothing else:

- **primitives** — `--gr-space-*`, `--gr-radius-*`, `--gr-text-*`, `--gr-z-*` —
  do not depend on the theme: there is no "dark version of a gap";
- the **derived states** of fills (`-hover`, `-active`) are not declared by a
  theme — they follow from the roles; the states of the button-weight fill,
  `-solid-hover` and `-solid-active`, are theme roles.

The practical rule: it changes when the theme switches → role; it is always the
same → primitive.

## Role suffixes

The most common mistake in a custom theme is to mix up roles and end up with
unreadable text. A colour role has up to six variants, and they are **not**
interchangeable:

| Suffix | What it is | Contrast threshold |
|---|---|---|
| no suffix | the saturated fill: badge background, indicator, border | ≥ 3\:1 against the page background |
| `-fg` | text **on** that fill | ≥ 4.5\:1 against the fill and its hover/active |
| `-solid` | a fill of button weight | ≥ 4.5\:1 against its own `-solid-fg` |
| `-solid-fg` | text on `-solid` | ≥ 4.5\:1 against `-solid` and its states |
| `-light` | a soft tinted surface | ≥ 3\:1 against the text lying on it |
| `-text` | text on a surface or on the page background | ≥ 4.5\:1 against `-light` and the background |

:::callout{type="warning"}
**A saturated tone must never be used as a text colour.** This is not about a
couple of unlucky tones: every one of them has a theme where it fails AA on an
ordinary surface — `--gr-success` scores 2.32 on the light theme, `--gr-primary`
3.70 on the dark one. The paired `-text` roles hold AA everywhere, 5.46 at the
worst. The foreground colour comes from `-text`; the tone stays a fill, a border
and an indicator.
:::

The rule is held by two of the library's gates rather than by memory: a static
one catches `text-[var(--gr-<tone>)]` in the sources, the second recomputes
contrast from the tokens of both themes — so that the list of forbidden tones
does not go stale the first time a theme is repainted.

Standing apart are the roles without a tone: `--gr-disabled-*` for a disabled
control and `--gr-invalid-*` for one that failed validation. By default `invalid`
points at danger, but that is a reference and not a copy: a validation error and
a decorative `state="danger"` are different messages, and a theme is free to pull
them apart by colour.

## Your own theme: assembled, not typed out

A role a theme did not declare is inherited **not** from its own base but from
`:root` — that is, from the light theme. A dark theme with one forgotten role
gets a light patch, and the one who sees it is the user, not the build. Hence the
price of a hand-written file: around ninety roles, and as much attention again
every time the package adds a new one.

So the theme is assembled by `@feugene/granularity/theme`:

```js title="theme.config.mjs"
import { extendTheme, tone } from '@feugene/granularity/theme'

const surfaces = {
  '--gr-bg': '#041e2b',
  '--gr-fg': '#e8f4fa',
  '--gr-card': '#0a2f42',
}

export const ocean = extendTheme({
  name: 'ocean', // selector [data-theme='ocean']
  base: 'dark',
  tokens: {
    ...surfaces,
    '--gr-primary': '#4fd1e0',
    '--gr-primary-fg': '#041e2b',
    ...tone('azure', '#38bdf8', { base: surfaces }),
  },
})

ocean.css // ready CSS, including the derived fallback for browsers without color-mix
```

What that buys beyond the sheer volume:

:::steps
1. **The theme does not rot.** A role the package adds tomorrow arrives from the base on the next build.
2. **The fallback is computed from your values** instead of being taken from the light theme, as it is for a hand-written file.
3. **The theme is verified.** WCAG contrast and tone separation by ΔE are computed at build time; a failure stops the build with the role's name and the ratio it got.
:::

`tone('success', '#3ddc97', { base })` derives the whole family from one colour —
the fill, `-fg`, `-solid` with its states, `-solid-fg`, `-light`, `-text`
(exactly the ones the tone has in the package registry) — by the rules in the
table above. Repainting a tone and leaving its family on the base is the classic
trap: a `success` icon on `--gr-success-light` scores 2.2\:1.

`createTheme` is the same thing without a base: an undeclared role stops the
build with a list. It is worth reaching for where the base gets in the way: a
high-contrast theme, somebody else's brand book, print.

| | `extendTheme` | `createTheme` |
|---|---|---|
| An undeclared role | comes from the base | the build fails |
| The package adds a new role | it arrives on its own | it demands a decision from the theme's author |
| When to take it | the theme is a variation of light or dark | the theme has a colour logic of its own |

## Wiring it up

Your own theme is ordinary application CSS, imported after the foundation layer:

```ts title="src/main.ts"
import './reset.css'
import 'virtual:granum.css'
import './styles/theme-ocean.css'
```

Your application's theme comes **after** the sheet: `virtual:granum.css` puts its own
into `granum.*` layers, and your application's unlayered CSS beats any layer
regardless of specificity. It wins by being outside the layers rather than by
order — but keep the order anyway, because that is how the intent reads.

## A third theme, declared rather than written

Your own theme can be part of the config, and then its values go through the same
resolution as the built-in ones:

```js title="granum.config.mjs"
defineGranumConfig({
  engine: windEngine(),
  providers: ['@feugene/granularity'],
  themes: {
    names: ['light', 'dark', 'ocean'],
    define: {
      ocean: {
        // The structure comes from an existing theme; only the colours change.
        extends: 'dark',
        selector: '[data-theme="ocean"]',
        tokens: { 'gr-primary': '#4fd1e0', 'gr-bg': '#062b32' },
      },
    },
  },
})
```

:::callout{type="warning"}
Tokens in `define` and `tokenOverrides` are written **without the `--` prefix**. The
generator adds it itself, and granum rejects a key with `--` as an `InvalidConfigError`
naming the path to the field.
:::

The v1 preset's `themes.themeFiles` key does not exist in granum: a config with it
fails with `InvalidConfigError`. A file next to the sheet is still a working path and
is described above; the difference is that it is no longer the only one.

A theme that only ever appears in the browser — a theme editor, a theme from user
settings — is wired up through the runtime subpath, which does not pull in the
token reference:

```ts
import { applyTheme } from '@feugene/granularity/theme/apply'

const remove = applyTheme(ocean.css, { name: 'ocean' })
```

## Switching at runtime

The canonical way is an attribute on the document root:

```ts
document.documentElement.dataset.theme = 'ocean'
```

`useTheme()` is typed as `'light' | 'dark'` and handles exactly those two:
persistence in `localStorage` under the key `gr-theme`, synchronisation between
tabs and watching `prefers-color-scheme`. It will not switch a third theme — that
needs a controller of your own writing `dataset.theme`. If there are exactly two
themes but with different colours, use the names `light` and `dark` and the
composable works as is.

:::callout{type="info"}
**Under SSR the theme state is declared explicitly.** On the server without the
plugin `useTheme()` returns read-only state: `theme` and `isDark` can be read,
while `setTheme`/`toggleTheme` throw — shared module state would carry one
user's choice into another user's response. Changing the theme on the server
goes through `granularityThemePlugin`. The same plugin is needed when several
applications with independent themes live on one page, and then it also requires
a `target`: without one they all write the attribute into the same `<html>` and
the last one wins.
:::

## Traps that have already been sprung

Each of these broke the light or the dark theme of the package itself:

1. **Secondary text checked against the page background.** `--gr-muted-fg` lives not on `--gr-bg` but on `--gr-muted` and `--gr-secondary` — measure against the background with the lowest contrast: the darkest of them in the light theme, the lightest in the dark one.
2. **`-fg` checked only against the base fill.** Hover and active mix `--gr-fg` into the fill — darker in the light theme, lighter in the dark one. Check all three states.
3. **`opacity` for disabled.** Transparency dilutes carefully checked tokens and drops the contrast. Mute with a background, not with transparency.
4. **Two tones told apart by eye.** `--gr-info` was an indigo two steps from `--gr-primary` — ΔE 4.3 against a noticeability threshold of 2.3 — while their text roles matched exactly. Compute ΔE between tones, not just the contrast of each.
5. **One shadow for both themes.** A semi-transparent dark on a dark background gives nothing: the surface loses one of its two channels of elevation. Elevation is declared separately in each theme.
6. **A soft surface shouts louder in the dark theme.** Measure not the absolute value but the distance from your own surface: equal hex codes do not give equal results.

## Per-component tokens

The global roles do not cover everything: components have variables of their own
that repaint them pointwise. The source is the `tokens.json` next to the code of
each component that has such variables, and the list with the nature and default of each is on the
component's own page in the [catalogue](/components) and in
[foundations](/foundations/tokens).
