Skip to content

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-independent
brand palette --color-brand-700: #4338ca per brand
semantic --color-text-accent: var(--color-brand-700) per brand, per theme
component --slider-accent-color: var(--color-text-accent) per component

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

Themes live on :root. Set data-theme on <html>:

ModeHow
systemdefault — follows prefers-color-scheme
darkdocument.documentElement.dataset.theme = 'dark'
lightdocument.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.

createBrand() generates a full palette and semantic layer from one seed colour, at build time:

scripts/generate-brand.mjs
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.

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';

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.

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.

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 */
}