gray
radius
Tokens

Theme

Five dials, set on an ancestor: light or dark, an accent, a gray, a radius and the level the page sits on. Twenty-three components follow and not one of them is rebuilt, because every stylesheet already reads the properties the dials rewrite.

Try it

The same dials as the toolbar above. Changing one here changes the whole site.

accentColor

green

neutralColor
radius
v0.1.0

Selected

A link

Tinted surfaces, borders and text all come off the same ramp.

components-react

Twenty-three components, one stylesheet each.

Appearance

A nested Theme that names an appearance paints the ground under it, so a dark region on a light page brings its own surface rather than inheriting one that no longer matches.

light
Production
dark
Production
<Theme appearance="dark">
<Button size="s">Deploy</Button>
<Badge tone="accent">Production</Badge>
</Theme>

Nesting

Wrap a region in a second Theme to give it its own accent. Omitting a prop inherits rather than resets.

teal · full
Popular
crimson · none
Legacy
<Theme accentColor="teal" radius="full">
<Button size="s">Upgrade</Button>
<Badge tone="accent">Popular</Badge>
</Theme>
<Theme accentColor="crimson" radius="none">
<Button size="s">Cancel plan</Button>
<Badge tone="accent">Legacy</Badge>
</Theme>

How it works

Two indirections in the token stylesheet, and nothing else anywhere. A component compiled against var(--accent-500) and var(--border-radius-8) follows a rebrand it was built years before.

/* one ramp, and a block per hue that replaces it */
html {
--accent-500: var(--green-500);
}
[data-accent='purple'] {
--accent-500: var(--purple-500);
}
/* radius is a multiplier, plus a flag for the shape it cannot describe */
html {
--border-radius-factor: 1;
--border-radius-pill-full: 0;
}
[data-radius='large'] {
--border-radius-factor: 1.5;
}
[data-radius='full'] {
--border-radius-pill-full: 999px;
}
/* the steps are restated wherever the factor is, so a nested Theme recomputes them */
html,
[data-radius] {
--border-radius-8: calc(0.5rem * var(--border-radius-factor));
}
/* and this is what a component was compiled against, years earlier */
.button {
background-color: var(--accent-500);
border-radius: max(var(--border-radius-8), var(--border-radius-pill-full));
}

Props

PropTypeDefaultNotes
appearance'light' | 'dark' | 'system'inheritedsystem is the absence of a choice rather than a third set of values: it writes no attribute and the stylesheet goes back to asking prefers-color-scheme.
accentColor14 hues: red · crimson · pink · magenta · purple · indigo · blue · cyan · teal · green · lime · yellow · amber · orangeinheritedThe text color that clears AA on each fill is measured per theme, so this cannot put white on yellow.
neutralColor'mono' | 'gray' | 'slate'inheritedWhich gray the surfaces and borders are drawn from. mono has no hue in it; gray and slate lean toward blue. Text does not move with it.
radius'none' | 'small' | 'medium' | 'large' | 'full'inheritedA multiplier over the radius scale. none squares pills too, which is what asking for no radius means.
pageBackground'base' | 'raised'inheritedWhich level the page sits on. A light-theme dial: dark has nothing above its page to move to.
hasBackgroundbooleanautoWhether this Theme paints the ground under it. Left alone it is true for the outermost one and for a nested one that names an appearance. Set it false to put a themed region on a background the page has already painted.