Skip to content

Guides

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.

styles.ts
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.

Panel.tsx
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.

system.ts
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 } = ui

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

CSS
: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.

build.ts
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:

main.tsx
import '@toned/themes/shadcn/config.css'

The file sets one property per token value:

TypeScript
/* @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.

TypeScript
/* 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:

TypeScript
/* 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);
}