Theming Guide
A stylesheet names roles such as fill: 'surface'; a theme supplies the values behind them. Declare the themes on the system, and on the web the build writes them as CSS custom properties. A stylesheet never names a theme.
The theme showcase renders one interface in six themes built this way.
Use a theme in a stylesheet
Nothing in a sheet refers to a theme. It uses the system's tokens, and the tokens read the theme.
import { stylesheet } from './system.ts'
export const panelStyles = stylesheet({
Root: { fill: 'surface', ink: 'default', radius: 'panel', padding: 'panel' },
Title: { $kind: 'text', ink: 'accent' },
})Switch theme
The first declared theme applies everywhere. To use another one, set data-theme on any element; it applies to that element and everything inside it, and scopes can nest. Changing the attribute changes no class and no inline style, so a theme can be switched without Toned doing any work.
import { createElements } from '@toned/react'
import { panelStyles } from './styles.ts'
const S = createElements(panelStyles)
export function Panel({ theme, title }: {
theme?: 'day' | 'night'
title: string
}) {
return (
<S>
<S.Root data-theme={theme}>
<S.Title as="h2">{title}</S.Title>
</S.Root>
</S>
)
}For a whole page, put the attribute on the <html> element. To follow the operating system, set it from a prefers-color-scheme media query listener before the first paint.
Declare the themes
A theme schema is a type: the fields every theme must supply. defineTokenFor binds tokens to it, so a resolver can read only those fields, and defineSystem checks each entry of themes against it. A theme that lacks a field does not compile.
import { defineSystem, defineTokenFor } from '@toned/core'
// What every theme supplies. Each field is a CSS value.
export type Theme = {
surface: string
ink: string
accent: string
radius: string
padding: string
}
const token = defineTokenFor<Theme>()
export const ui = defineSystem({
id: 'app',
tokens: {
fill: token({
values: ['surface', 'accent'],
resolve: (value, theme) => ({
backgroundColor: value === 'accent' ? theme.accent : theme.surface,
}),
}),
ink: token({
values: ['default', 'accent'],
resolve: (value, theme) => ({
color: value === 'accent' ? theme.accent : theme.ink,
}),
}),
radius: token({
values: ['panel'],
resolve: (_value, theme) => ({ borderRadius: theme.radius }),
}),
padding: token({
values: ['panel'],
resolve: (_value, theme) => ({ padding: theme.padding }),
}),
},
themes: {
day: { surface: '#ffffff', ink: '#17234b', accent: '#284bdd', radius: '8px', padding: '16px' },
night: { surface: '#10162f', ink: '#e8edff', accent: '#8fa5ff', radius: '12px', padding: '20px' },
},
})
export const { stylesheet } = uiWhat the build writes
buildStyles and the Vite plugin add the themes to the stylesheet they generate. A resolver's theme.surface becomes var(--app-surface) in a generated class, and each theme sets those properties: the first one on :root, and every one under its data-theme selector.
:root { --app-surface: #ffffff; --app-ink: #17234b; --app-accent: #284bdd; --app-radius: 8px; --app-padding: 16px; }
[data-theme='day'] { --app-surface: #ffffff; --app-ink: #17234b; --app-accent: #284bdd; --app-radius: 8px; --app-padding: 16px; }
[data-theme='night'] { --app-surface: #10162f; --app-ink: #e8edff; --app-accent: #8fa5ff; --app-radius: 12px; --app-padding: 20px; }
.app--fill_surface { background-color: var(--app-surface); }The themes option chooses another default, or leaves the themes out so that the application can deliver them itself. generateThemes returns the theme CSS alone.
import { buildStyles, generateThemes } from '@toned/core/build'
import { panelStyles } from './styles.ts'
import { ui } from './system.ts'
const sheets = [panelStyles]
// Classes and themes; 'day' is the default because it is declared first.
export const artifact = buildStyles(ui, { sheets })
// 'night' on :root instead.
export const nightFirst = buildStyles(ui, { sheets, themes: { default: 'night' } })
// Classes only, and the themes as a separate stylesheet.
export const classesOnly = buildStyles(ui, { sheets, themes: false })
export const themeCss = generateThemes(ui)Limits
Only top-level string and number fields of a theme are written. A nested group such as colors.primary cannot be read through one custom property; keep the schema flat, or pass those values to a renderer as explicit tokens.
A generated class sets each side of a box separately. A field read by padding, margin, inset or borderWidth must therefore hold one value: the build refuses 3px 0 0 0 there. Use one field per side.
On React Native there are no custom properties. Give the renderer concrete token values, and pass a changed set through the provider's theme prop; see the React reference.
Themes for the base system
The optional base system in @toned/systems reads custom properties with fixed names, and @toned/themes ships values for them. Import the theme's stylesheet once:
import '@toned/themes/shadcn/config.css'The file sets one property per token value:
/* @toned/themes/shadcn/config.css (simplified) */
:root {
--colors_base_50: hsl(0 0% 100%);
--colors_base_950: hsl(240 10% 3.9%);
--colors_bg_default: var(--colors_base_50);
--colors_text_default: var(--colors_base_950);
--colors_bg_action: hsl(240 5.9% 10%);
--colors_text_on_action: hsl(0 0% 100%);
--radius_small: 0.25rem;
--radius_medium: 0.5rem;
--radius_large: 0.75rem;
/* ... */
}Dark mode
The packaged theme sets light values on :root only. For dark mode, set the same properties under a selector of your own, such as a .dark class, and add or remove it on the <html> element; the stylesheets do not change.
/* dark.css: imported after the packaged theme */
.dark {
--colors_bg_default: hsl(222.2 84% 4.9%);
--colors_text_default: hsl(210 40% 98%);
--colors_bg_action: hsl(210 40% 98%);
--colors_text_on_action: hsl(222.2 47.4% 11.2%);
/* ... */
}A custom theme
To theme the base system yourself, write a stylesheet that sets the same properties and import it in place of the packaged one:
/* my-theme.css */
:root {
/* Colour tokens */
--colors_bg_default: hsl(0 0% 98%);
--colors_text_default: hsl(240 10% 10%);
--colors_bg_action: hsl(220 90% 56%);
--colors_text_on_action: hsl(0 0% 100%);
--colors_bg_muted: hsl(220 14% 96%);
--colors_bg_elevated: hsl(0 0% 100%);
--colors_border_subtle: hsl(220 13% 91%);
/* Border radius tokens */
--radius_small: 3px;
--radius_medium: 5px;
--radius_large: 10px;
--radius_full: 9999px;
/* Shadow tokens */
--shadow_small: 0 1px 3px rgba(0,0,0,0.1);
--shadow_medium: 0 4px 12px rgba(0,0,0,0.1);
}