Theming and brands
Everything visual resolves through a four-tier chain of CSS custom properties. Components read the top of the chain and know nothing about what is beneath.
primitives --space-sm, --radius-full, --duration-fast brand-independentbrand palette --color-brand-700: #4338ca per brandsemantic --color-text-accent: var(--color-brand-700) per brand, per themecomponent --slider-accent-color: var(--color-text-accent) per componentSo a single rule in slider.css —
.slider { accent-color: var(--slider-accent-color);}— produces a different colour in dark mode, and a different colour again under another brand,
without that rule or slider.tsx changing at all.
Light, dark and system
Section titled “Light, dark and system”Themes live on :root. Set data-theme on <html>:
| Mode | How |
|---|---|
| system | default — follows prefers-color-scheme |
| dark | document.documentElement.dataset.theme = 'dark' |
| light | document.documentElement.dataset.theme = 'light' |
Why :root and not a wrapper
Custom properties inherit through the DOM tree, and React portals move nodes out of it. A
popup portaled into <body> would not inherit tokens scoped to a wrapper element. Declaring
at :root means portaled content inherits correctly with no extra machinery.
Custom brands
Section titled “Custom brands”createBrand() generates a full palette and semantic layer from one seed colour, at build time:
import { writeFile } from 'node:fs/promises';import { createBrand } from '@arun-dev/tokens/createBrand';
await writeFile('src/brand.css', createBrand({ name: 'acme', seed: '#0ea5e9' }));Then import src/brand.css in place of @arun-dev/tokens/brands/default. It emits the light,
dark and system variants together, so theming keeps working.
Contrast is guaranteed for a seed
Section titled “Contrast is guaranteed for a seed”Any seed colour gives a brand that meets WCAG contrast, in light and in dark. Text is at least 4.5:1 on its background, and 7:1 where the default brand is audited AAA (body text, and the accent on the page and under on-accent text). Icons and outlines are at least 3:1.
createBrand() checks every pair of tokens the components draw one on the other, listed in
CONTRAST_REQUIREMENTS. A shade that falls short is made darker or lighter until it passes,
keeping its hue. A bright seed such as yellow gets a deeper 700, for example, so white text on a
primary Button stays readable. The other shades then move to keep the scale in order.
You can also pass a full palette instead of a seed if you already have one:
createBrand({ name: 'acme', palette: { 50: '#…', 100: '#…', /* … */ 950: '#…' } });A palette you pass is used exactly as given, so check it yourself with the same list:
import { contrastRatio, CONTRAST_REQUIREMENTS } from '@arun-dev/tokens/createBrand';A published brand: arun
Section titled “A published brand: arun”The arun brand is also published, generated from the same input, so an app can use it without
running createBrand() itself:
@import '@arun-dev/tokens/base';@import '@arun-dev/tokens/brands/arun'; /* in place of brands/default */It is createBrand(ARUN_BRAND) byte for byte — a test fails if the two ever differ — so an app
importing it and this site, which generates it, look the same. ARUN_BRAND is exported from
@arun-dev/tokens/createBrand for a build that wants to generate it, or start from it.
One brand per document
Section titled “One brand per document”Brand and theme tokens are declared at :root, never scoped to a subtree, so a document
has exactly one brand. Rendering two brands on one page is deliberately not supported —
custom properties inherit through the DOM tree, and a React portal moves nodes out of it,
so a popup portaled into <body> would not inherit tokens scoped to a wrapper element.
To show a different brand, load a different brand stylesheet.
Restyling one component
Section titled “Restyling one component”Override at the tier that matches the blast radius you want:
:root { --color-text-accent: #7c3aed; /* every accent surface in the system */ --chip-accent-bg: #f5f3ff; /* only accent chips */}