Theming
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.
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-tokenspackage; it arrives as a dependency of the core, so there is nothing to install separately; - a mode is a variant inside the theme:
light,darkor 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.
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:
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 withoutcolor-mixthe build adds precomputed values; the states of the button-weight fill,-solid-hoverand-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 bygranum 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—lightordark: from it the build declarescolor-schemefor native controls, and the application picks a mode when it follows the system scheme and computesisDark;- 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:
| 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 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.
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-mixWhat that buys beyond the sheer volume:
- The mode does not rot. A role the package adds tomorrow arrives from the base on the next build.
- 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.
- 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 andcolorSchemecome 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.
extendMode | createMode | |
|---|---|---|
| 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 mode’s author |
| When to take it | the mode is a variation of light or dark | the 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:
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:
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:
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.
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:
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:
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')<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>themeis the current theme andmodethe current mode (bothComputedRef);themesare the build themes;modesare the current theme’s modes withcolorSchemeand pairs — a reactive array thatsetThemeupdates 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.isDarklooks at the mode’scolorScheme, not its name:oceanon 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 frommodes.defineincluded. A mode the build does not have is an error.- The
gr-themekey 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 systemprefers-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:
<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:
- The
--gr-*roles and formulas built on them. They change with the mode by themselves and work correctly in islands — they cover almost everything. - 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. light-dark(light, dark). The build declarescolor-schemein each mode’s block, and the function takes the value by the scheme of the nearest one — inside an island as well. Colours only.- The pair’s suffix —
[data-theme$='-dark']: by the mode’s name, independent of the theme. Unambiguous, because a mode name has no hyphen. - The whole pair —
[data-theme='slate-dark']: only for a change that really belongs to one theme.
.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:
- 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 mode, the lightest in the dark one. -fgchecked only against the base fill. Hover and active mix--gr-fginto the fill — darker in the light mode, 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 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.
- 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.tokenOverridesor 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”.