Theming

  • Concepts
  • 18 min
  • 12 snippets

The theme is chosen at build time, the mode at run time: how to rebrand without touching a single component, and why a mode is assembled, not typed out.

Reviewed 2026-10-27

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.

Theme and mode

These are two different words, and mixing them up is expensive:

  • a theme is a whole design language: primitives, roles and derived states. Themes are chosen at build time: an application has exactly one default theme, and additional ones travel in sheets of their own and are switched on at run time. The built-in theme is Slate, slate, from the @feugene/granularity-tokens package; it arrives as a dependency of the core, so there is nothing to install separately;
  • a mode is a variant inside the theme: light, dark or your own. The set of modes is chosen at build time, and switching between them happens at run time: every mode of the build is already in the CSS.

A mode is switched on by a theme-mode pair in an attribute on the root — <html data-theme="slate-dark">. Without the attribute the default mode of the default theme applies: its block also sits on :root. There are no .light and .dark classes.

granum.config.mjs
import { windEngine } from '@feugene/granum-engine-wind'
import { defineGranumConfig } from '@feugene/granum/vite'

export default defineGranumConfig({
  engine: windEngine(),
  providers: ['@feugene/granularity'],
  themes: [{ theme: 'slate', modes: ['light', 'dark'] }],
})

Without themes the build takes slate. An entry without modes gives the theme a single mode — its default one, light: dark and the rest are named explicitly. Which themes the catalogue has and how they differ is in the Themes section.

The shortest thing that works

A couple of colours of a mode can be changed without creating a single file:

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

Rebuild. The value lands in the block of the slate-dark pair after the theme data 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 mode in their own right: solid buttons are painted by -solid, links by -text. When you repaint a tone, derive the whole family with tone(). The root key in place of a mode name changes the primitives — what is the same in every mode.

What a theme is made of

A theme is a complete set of values for the same tokens. The names are shared by every theme and set by the registry of the token package, which is why components know nothing about themes. A theme contains:

  • metadata — the id, title, description, the default mode and the list of modes with their colorScheme; a theme can extend another (extends: 'slate') and declare only the differences;
  • primitives — --gr-space-*, --gr-radius-*, --gr-text-*, --gr-z-* — do not depend on the mode: there is no “dark version of a gap”;
  • roles and shadows of each mode (--gr-bg, --gr-primary, --gr-danger-text, …): surfaces, text, actions, statuses, solid fills;
  • the derived states of fills (-hover, -active) are not declared by a theme — they follow from the roles, and for browsers without color-mix the build adds precomputed values; the states of the button-weight fill, -solid-hover and -solid-active, are mode roles;
  • component values — the components/@feugene/granularity/<GrX>/ section: the names of a component’s tokens are a contract of the core, the values are the theme’s decision (a button’s color, a badge’s radius, a line’s thickness). A component reads them without a fallback, and a missing one is named by granum doctor;
  • default props — what a token cannot express: the look of the loading indicator, the shape of a button group;
  • a README — what the theme is for and the rules that tokens do not carry; the theme’s page in the Themes section shows it.

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

What a mode has

  • a name — Latin letters and digits without a hyphen: the hyphen separates the theme from the mode in the pair;
  • colorScheme — light or dark: from it the build declares color-scheme for native controls, and the application picks a mode when it follows the system scheme and computes isDark;
  • tokens — roles and shadows; a mode can extend another mode of the same theme (extends) and keep only the differences;
  • the default mode — the theme’s defaultMode; for the default theme its block also sits on :root, so a page without the attribute is already styled.

A mode can also be added from the application’s side — modes.define, described below: for useTheme it is no different from a mode of the theme.

Role suffixes

The most common mistake in a custom mode 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 mode where it fails AA on an ordinary surface — --gr-success scores 2.32 in light, --gr-primary 3.70 in dark. 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 modes — so that the list of forbidden tones does not go stale the first time a mode 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 mode is free to pull them apart by colour.

Your own mode: assembled, not typed out

A role a mode did not declare is inherited not from its own base but from :root — that is, from the default mode. A dark mode 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 mode is assembled by @feugene/granularity-tokens/theme. You can import the token package directly only after declaring it in the application’s package.json: it does arrive as a dependency of the core, but a strict package manager will not let you import somebody else’s dependency.

theme.config.mjs
import { extendMode, tone } from '@feugene/granularity-tokens/theme'

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

export const ocean = extendMode({
  name: 'ocean', // pair slate-ocean
  title: 'Ocean',
  base: 'dark',
  tokens: {
    ...surfaces,
    '--gr-primary': '#4fd1e0',
    '--gr-primary-fg': '#041e2b',
    ...tone('azure', '#38bdf8', { base: surfaces }),
  },
})

ocean.definition // input for modes.define: extends, colorScheme and the differences only
ocean.css // CSS under the pair, with the derived fallback for browsers without color-mix

What that buys beyond the sheer volume:

  1. The mode does not rot. A role the package adds tomorrow arrives from the base on the next build.
  2. The mode 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.
  3. The base is any mode of any theme: 'dark' of the built-in one, { theme, mode } of your own theme, or another assembled mode. The pair and colorScheme come from the base.

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.

createMode 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 mode, somebody else’s brand book, print.

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

The mode in the config

The assembled mode is handed to the granum config. It takes its own pair, slate-ocean, next to the theme’s modes and gets its component tokens from the base:

granum.config.mjs
import { windEngine } from '@feugene/granum-engine-wind'
import { defineGranumConfig } from '@feugene/granum/vite'
import { ocean } from './theme.config.mjs'

export default defineGranumConfig({
  engine: windEngine(),
  providers: ['@feugene/granularity'],
  themes: [{
    theme: 'slate',
    modes: {
      names: ['light', 'dark', 'ocean'],
      define: { ocean: ocean.definition },
    },
  }],
})

A mode can also be declared right in the config — extends, colorScheme and the differences only. It is shorter, but the contrast check extendMode gives you is not there:

granum.config.mjs
defineGranumConfig({
  engine: windEngine(),
  providers: ['@feugene/granularity'],
  themes: [{
    theme: 'slate',
    modes: {
      names: ['light', 'dark', 'ocean'],
      define: {
        ocean: { extends: 'dark', colorScheme: 'dark', 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. A mode named like one the theme already has is rejected too (activation-collision): every pair has a value of its own.

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

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

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

The build’s mode switch does not know about such a mode: setMode only accepts modes from the manifest, so the editor sets the slate-ocean pair itself.

Your own theme

A mode is a variant of the same design system. A theme is a different design language: its own primitives (radii, fonts) and its own modes. Your own theme is a folder laid out like the built-in ones — theme.json with "extends": "slate", root.json with the primitives and modes/<mode>.json with the roles — and it declares only what differs from the base.

granum.config.mjs
import { loadTheme } from '@feugene/granularity-tokens/theme/node'

const brand = loadTheme(new URL('./src/themes/brand/', import.meta.url))

export default defineGranumConfig({
  engine: windEngine(),
  providers: ['@feugene/granularity'],
  themes: [{ theme: brand, modes: ['light', 'dark'] }],
})

loadTheme checks completeness against the registry, contrast and tone separation in every mode, and fails naming the token and the mode: an incomplete or unreadable theme never reaches the user. An heir takes the component values from the theme in extends — and gets its edits on the next build. A language of its own starts from a copy: gr-theme-copy slate brand lays down a folder with all of Slate’s values, and from then on the theme lives by itself — a new core token reaches it not silently but as a granum doctor finding.

A build can carry several themes. The default one is unique — the entry with default: true — and lives in the shared sheet. Each additional theme gets a sheet of its own with the full blocks of its pairs:

granum.config.mjs
defineGranumConfig({
  engine: windEngine(),
  providers: ['@feugene/granularity'],
  themes: [
    { theme: 'slate', default: true, modes: ['light', 'dark'] },
    { theme: brand, modes: ['light', 'dark'] },
  ],
})

Vite emits the additional theme’s sheet as an asset and puts its address into the virtual:granum/themes manifest: the theme is switched on by setTheme or by a GrConfigProvider island — see below, and the sheet loads only then. A page that always shows the theme imports the sheet statically — virtual:granum/themes/brand.css, or virtual:granum/themes.css for every additional theme at once.

Switching at runtime

The theme and the mode are switched by useTheme(), which stands on granum’s controller. The build themes, their modes and pairs are known to the build and handed over as the virtual:granum/themes manifest; pass it once, where the state is created:

src/main.ts
import { initThemeEarly } from '@feugene/granularity'
import manifest from 'virtual:granum/themes'
import { createApp } from 'vue'
import App from './App.vue'

initThemeEarly({ manifest }) // before mounting: no mode flash
createApp(App).mount('#app')
src/components/ModeToggle.vue
<script setup lang="ts">
import { useTheme } from '@feugene/granularity'

const { isDark, toggleMode } = useTheme()
</script>

<template>
  <button type="button" @click="toggleMode()">
    {{ isDark ? 'Light mode' : 'Dark mode' }}
  </button>
</template>
  • theme is the current theme and mode the current mode (both ComputedRef); themes are the build themes; modes are the current theme’s modes with colorScheme and pairs — a reactive array that setTheme updates in place, so destructuring keeps up with a theme change.
  • setTheme(id, { mode? }) loads the additional theme’s sheet first and only then sets the pair: a theme’s values without its sheet would be someone else’s. The mode is the given one, otherwise the one named like the current, otherwise one of the same scheme, otherwise the default.
  • isDark looks at the mode’s colorScheme, not its name: ocean on top of dark is dark.
  • toggleMode() switches between the first light and the first dark mode of the current theme, setMode() to any of its modes, your own from modes.define included. A mode the build does not have is an error.
  • The gr-theme key stores not the pair but the choice: the mode name for the default theme, <theme>-<mode> for an additional one. A default-theme mode survives a change of theme in the build. Until a choice is stored, the mode follows the system prefers-color-scheme; a choice made in another tab is applied here too.
  • A pair already on the root (server HTML, a script in <head>) beats the storage: the controller recognises it and leaves it alone.

Without manifest, slate with the light and dark modes applies. An application with another theme, several themes or modes of its own must pass the manifest — otherwise the slate-* pairs match no selector in its CSS.

The attribute is not written by hand: data-theme="dark" matches no selector — it takes a pair, and the build is what knows it. Your own CSS for dark modes uses the [data-theme$='-dark'] selector or the full pair; colours taken from the --gr-* tokens need no selector at all. The dark: variant in utilities works unchanged: granum builds it from the pairs of the dark modes.

Under SSR the mode state is declared explicitly. On the server without the plugin useTheme() returns read-only state: mode and isDark can be read, while setMode/toggleMode throw — shared module state would carry one user’s choice into the next user’s response. So the server gets granularityThemePlugin with the same manifest. The same plugin is needed when several applications with independent modes live on one page, and then it also requires a target: without one they all write the pair into the same <html> and the last one wins. How to set the mode before the first frame is on the SSR page.

A mode island and a theme island

GrConfigProvider puts a pair on its subtree:

vue
<GrConfigProvider mode="dark">
  <SettingsPanel />
</GrConfigProvider>

<GrConfigProvider theme="brand" mode="dark">
  <BrandPreview />
</GrConfigProvider>

mode="dark" is a mode island of the current theme: data-theme="slate-dark" on the wrapper. theme together with mode is an island of another build theme: it loads the additional theme’s sheet by the address in the manifest and takes that theme’s default props. A theme missing from the config’s themes is not in the CSS at all — the island finds no selector. Overlay panels inside carry the pair over to body on their own. The document’s mode is the job of useTheme, not of the provider.

The dark prop of GrBadge and GrChip is not a mode: it is a dark chip, a dense label even on a light page.

Your own CSS per mode

Components switch through roles on their own. For your own CSS that looks different in the dark, pick the tool in this order — from one that knows neither the theme nor the mode to one that knows everything about them:

  1. The --gr-* roles and formulas built on them. They change with the mode by themselves and work correctly in islands — they cover almost everything.
  2. The dark: variant in markup. granum builds it from the scheme of the build’s modes: every mode with a dark scheme is in it, your own ones too.
  3. light-dark(light, dark). The build declares color-scheme in each mode’s block, and the function takes the value by the scheme of the nearest one — inside an island as well. Colours only.
  4. The pair’s suffix — [data-theme$='-dark']: by the mode’s name, independent of the theme. Unambiguous, because a mode name has no hyphen.
  5. The whole pair — [data-theme='slate-dark']: only for a change that really belongs to one theme.
css
.hero {
  background: light-dark(#f8fafc, #0b1120);
}

[data-theme$='-dark'] .hero__art {
  display: none;
}

An ancestor selector — dark:, the suffix, the pair — matches inside a light island on a dark page too: the dark ancestor is still an ancestor. Roles and light-dark() do not know this trap — the value comes from the nearest mode.

Traps that have already been sprung

Each of these broke the light or the dark mode 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 mode, 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 mode, 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 modes. 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 mode.
  6. A soft surface shouts louder in the dark mode. 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. Their names and nature are in the tokens.json next to the component’s code, and the list with the default of each is on the component’s own page in the catalogue and in foundations. There are two natures, and they answer the question “whose decision is it”:

  • a theme token is a design decision: a color, a shadow, a radius, a duration. Every theme supplies the value, and the application overrides it with modes.tokenOverrides or its own CSS;
  • a hook is a handle of the application or the instance. The theme does not set it, the component reads it with a fallback, and it can be set with an inline style on the component itself.

Buttons can be repainted without them: it is enough to give the mode --gr-{tone}-solid and -solid-fg, while --gr-button-* stay the point for “this button must differ from the shared role”.