Theming

  • Concepts
  • 9 min
  • 6 snippets

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

Reviewed 2026-10-16

On this page

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:

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:

SuffixWhat it isContrast threshold
no suffixthe saturated fill: badge background, indicator, border≥ 3:1 against the page background
-fgtext on that fill≥ 4.5:1 against the fill and its hover/active
-solida fill of button weight≥ 4.5:1 against its own -solid-fg
-solid-fgtext on -solid≥ 4.5:1 against -solid and its states
-lighta soft tinted surface≥ 3:1 against the text lying on it
-texttext on a surface or on the page background≥ 4.5:1 against -light and the background

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:

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:

  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.

extendThemecreateTheme
An undeclared rolecomes from the basethe build fails
The package adds a new roleit arrives on its ownit demands a decision from the theme’s author
When to take itthe theme is a variation of light or darkthe theme has a colour logic of its own

Wiring it up

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

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:

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' },
      },
    },
  },
})

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.

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 and in foundations.