Theming
Roles, not hex codes: how to rebrand without touching a single component, and why a theme is assembled rather than typed out.
Machine-translated, not yet reviewed. Read the original
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:
presetGranularNode({
providers: [granularityProvider],
themes: {
names: ['light', 'dark'],
tokenOverrides: {
dark: { '--gr-primary': '#4fd1e0' },
},
},
})Rebuild — the buttons, links, focus rings and active states move on their own:
the derived values (-hover, -active) are computed by a color-mix formula
from the role rather than written down as separate values.
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”; - derived states are not declared by a theme: they follow from the roles and adjust themselves.
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, -solid-fg, -light, -text — 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 '@unocss/reset/tailwind-compat.css'
import 'virtual:uno:granular.css'
import './styles/theme-ocean.css'
import 'virtual:uno.css'A third theme cannot be added through the preset’s themeFiles. Despite the
name, that is a map of “theme name → file” which replaces the CSS of an
existing theme; themes come from the intersection of themes.names with what the
provider declared, and the name ocean has nowhere to come from there.
themeFiles is the right tool exactly when there are still two themes and it is
the colours that change — and then the file has to declare every role, which
means being assembled rather than written.
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. Without the plugin
useTheme() keeps it at module level — on the server that is one state for every
request, and one user’s choice would leave in another user’s response. Reading
the theme on the server is fine; changing it 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 darkest of the backgrounds it appears on. -fgchecked only against the base fill. Hover and active push the fill darker — 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 each
component’s code, and the list with the nature and default of each is on the
component’s own page in the catalogue and in
foundations.