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:

uno.config.ts
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:

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, -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.

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 '@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:

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. 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:

  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 darkest of the backgrounds it appears on.
  2. -fg checked only against the base fill. Hover and active push the fill darker — 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 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.

Last reviewed: 2026-09-01