Theming
Roles, not hex codes: how to rebrand without touching a single component, and why a theme is assembled rather than typed out.
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:
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-hoverand-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 |
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:
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-mixWhat that buys beyond the sheer volume:
- The theme does not rot. A role the package adds tomorrow arrives from the base on the next build.
- The fallback is computed from your values instead of being taken from the light theme, as it is for a hand-written file.
- 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:
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:
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:
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:
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:
- Secondary text checked against the page background.
--gr-muted-fglives not on--gr-bgbut on--gr-mutedand--gr-secondary— measure against the background with the lowest contrast: the darkest of them in the light theme, the lightest in the dark one. -fgchecked only against the base fill. Hover and active mix--gr-fginto the fill — darker in the light theme, lighter in the dark one. Check all three states.opacityfor disabled. Transparency dilutes carefully checked tokens and drops the contrast. Mute with a background, not with transparency.- Two tones told apart by eye.
--gr-infowas 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. - 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.
- 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.