Theming
TOUI uses a 3-tier DTCG token system — base → semantic → component — compiled by Style Dictionary into CSS custom properties. Themes bundle color, shape, and spacing personality into a single data-theme attribute.
Overview
TOUI's theming system is built on three principles:
- Single source of truth — all design decisions live in DTCG-format JSON under
tokens/ - CSS custom properties everywhere — no hard-coded values in components, everything is a
var(--...) - Orthogonal axes — color theme (
data-theme) and light/dark mode (.darkclass) are independent and compose freely
3-Tier Token Architecture
Tokens are organized in three tiers. Each tier can only reference tokens from the same tier or below.
| Tier | Source | Output | Purpose |
|---|---|---|---|
| 1 — Base | tokens/base/** | :root | Raw palette values, brand settings, motion curves |
| 2 — Semantic | tokens/semantic/** | :root + .dark + @theme | Contextual roles: --primary, --background, --radius, --component-height-md |
| 3 — Component | tokens/components/** | :root | Per-component defaults that point to semantic tokens |
How it builds
The pipeline runs via Style Dictionary v5:
npm run tokens:buildOutputs two files:
tokens/output/tokens.css→:root {}+.dark {}— pure CSS custom properties consumed at runtimetokens/output/tokens.theme.css→@theme inline {}+@theme {}— Tailwind v4 theme layer for utility class generation
Both are imported in styles/globals.css in the correct order:
/* Must come before @import "tailwindcss" */
@import "../tokens/output/tokens.theme.css";
@import "tailwindcss";
/* Runtime vars — after Tailwind so they win specificity */
@import "../tokens/output/tokens.css";Semantic Colors
All component colors reference semantic tokens, never raw palette values.
The full set of semantic color tokens:
:root {
/* Surface */
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
/* Brand */
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
/* Feedback */
--destructive: oklch(0.577 0.245 27.325);
--info: oklch(0.623 0.214 259.815);
--success: oklch(0.627 0.194 149.214);
--warning: oklch(0.769 0.188 70.08);
/* Chrome */
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
/* ... full dark overrides */
}Component Scale Tokens
Beyond colors, Tier 2 defines a scale system for heights, padding, border radii, and gaps that all components use.
The scale table
| Token | xs | sm | md | lg | xl |
|---|---|---|---|---|---|
--component-height-* | 24px | 32px | 36px | 40px | 44px |
--component-px-* | 8px | 12px | 16px | 20px | 24px |
--component-radius-* | --radius-sm | --radius-md | --radius-lg | --radius-xl | --radius-2xl |
--component-gap-* | 4px | 6px | 8px | 12px | 16px |
The radius cascade: overriding just --radius propagates through the entire scale:
--radius: 0.625rem; /* ← single source */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--component-radius-sm: var(--radius-md);
--component-radius-md: var(--radius-lg); /* ← used by most components */
--component-radius-lg: var(--radius-xl);Tailwind v4 usage
These tokens are registered in @theme so Tailwind generates utility classes for them:
// Height via component scale
<div className="h-(--component-height-md)" />
// Or use the custom utility shorthand (defined in globals.css)
<div className="hc-md" /> // height
<div className="p-comp-md" /> // horizontal padding
<div className="rc-md" /> // border radius
<div className="gap-comp-md" /> // gapBase Styles Reference
Base Colors Reference
Dark Mode
Dark mode is handled by next-themes toggling the .dark class on <html>. All .dark overrides live in tokens.css.
Setup
import { ThemeProvider } from '@/components/providers'
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
)
}'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function ThemeProvider({ children, ...props }) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}Tailwind dark variant
The dark variant is configured to match .dark descendants:
@custom-variant dark (&:is(.dark *));This means you can use dark: prefix on any Tailwind class:
<div className="bg-background dark:bg-background text-foreground dark:text-foreground" />Color Themes
Color themes extend the semantic layer by overriding a small set of CSS variables, scoped to [data-theme="X"] on <html>. This axis is completely orthogonal to dark mode — they compose freely.
How it works
Each theme overrides only the tokens that differ from the default theme:
[data-theme='rose'] {
--primary: oklch(0.645 0.246 16.439);
--primary-foreground: oklch(0.985 0.016 12.422);
--ring: oklch(0.645 0.246 16.439);
--sidebar-primary: oklch(0.645 0.246 16.439);
--radius: 0.75rem; /* ← shape personality */
--component-height-md: calc(var(--spacing) * 9.5); /* ← slightly taller */
}
[data-theme='rose'].dark {
--primary: oklch(0.708 0.265 16.439);
--ring: oklch(0.708 0.265 16.439);
}Built-in themes
| Theme | --radius | Heights | Padding | Personality |
|---|---|---|---|---|
default | 0.625rem | 36px md | 16px md | Neutral · balanced |
rose | 0.75rem | 38px md (+) | 16px md | Soft · rounded |
blue | 0.25rem | 36px md | 12px md (−) | Sharp · minimal |
green | 1rem | 40px md (+) | 20px md (+) | Spacious · open |
orange | 1.5rem | 36px md | 20px md (+) | Pill · playful |
Applying a theme at runtime
// Set the theme
document.documentElement.setAttribute('data-theme', 'rose')
// Remove (back to default)
document.documentElement.removeAttribute('data-theme')
// Persist to localStorage
localStorage.setItem('color-theme', 'rose')To prevent flash of unstyled content, the layout inlines a blocking script that restores the theme before first paint:
<script dangerouslySetInnerHTML={{ __html: `
try {
var colorTheme = localStorage.getItem('color-theme');
if (colorTheme && colorTheme !== 'default') {
document.documentElement.setAttribute('data-theme', colorTheme);
}
} catch (_) {}
`}} />The ThemeSwitcher component
import { ThemeSwitcher } from '@/components/layout/theme-switcher'
// Renders color swatches — click to switch theme
<ThemeSwitcher />Adding a Custom Theme
Define the CSS overrides
Create tokens/output/themes/my-theme.css:
[data-theme='my-theme'] {
/* Required: primary brand color */
--primary: oklch(0.55 0.22 300); /* purple */
--primary-foreground: oklch(0.985 0 0);
--ring: oklch(0.55 0.22 300);
--sidebar-primary: oklch(0.55 0.22 300);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-ring: oklch(0.55 0.22 300);
/* Optional: shape personality */
--radius: 0.5rem;
/* Optional: size personality */
--component-height-md: calc(var(--spacing) * 10);
--component-px-md: calc(var(--spacing) * 5);
}
[data-theme='my-theme'].dark {
--primary: oklch(0.68 0.20 300);
--ring: oklch(0.68 0.20 300);
}Import in globals.css
@import "../tokens/output/themes/my-theme.css";Register in the theme registry
export const COLOR_THEMES: ColorTheme[] = [
// ... existing themes
{
name: 'my-theme',
label: 'Purple',
description: 'Bold · vibrant',
primary: 'oklch(0.55 0.22 300)',
primaryDark: 'oklch(0.68 0.20 300)',
},
]That's all. The ThemeSwitcher component reads the registry automatically and shows a new swatch.
Customizing the Default Theme
To change the base design language without using the theme switcher, override CSS variables directly in styles/globals.css after the token imports:
@import "../tokens/output/tokens.theme.css";
@import "tailwindcss";
@import "../tokens/output/tokens.css";
/* Override defaults */
:root {
--primary: oklch(0.55 0.22 300); /* new brand color */
--radius: 0.5rem; /* sharper corners */
}Or via DTCG tokens — edit tokens/semantic/colors.json and tokens/semantic/radii.json, then run:
npm run tokens:buildTheming at Component Level
Every component in TOUI is fully token-driven. No hard-coded values exist in component files — each pixel of size, spacing, radius, and color traces back through a chain of CSS variables to a single source of truth in tokens/.
The full token chain
The path from a DTCG JSON value to a rendered pixel follows five steps:
The key insight: each layer delegates to the one below it using var(). This means changing a value at any layer instantly propagates down — the browser resolves the chain at paint time with no JavaScript involved.
How var() resolution works at runtime
When the browser paints a button with [data-theme="rose"] active, it resolves the --button-radius chain like this:
Overriding --radius: 0.75rem in [data-theme="rose"] is all it takes to re-shape every component simultaneously. The chain resolves lazily — the override only needs to exist where it diverges from the default.
Anatomy of a component's token files
Each component exposes three files that together form its theming surface:
registry/default/ui/button/
tokens.ts ← CSS variable name constants + Tailwind class maps
variants.ts ← CVA definition — consumes tokens, defines variant API
base.tsx ← React component — applies variants via className
tokens.ts — the only file that knows CSS variable names:
// CSS variable name constants — centralised for grep/rename
export const BUTTON_VARS = {
height: { md: '--button-height-md' },
px: { md: '--button-px-md' },
radius: '--button-radius',
}
// Tailwind class maps — one string per dimension per size
export const buttonSizes = {
md: {
height: 'h-(--button-height-md)', // Tailwind v4: reads the CSS var
px: 'px-(--button-px-md)',
radius: 'rounded-(--button-radius)',
text: 'text-(length:--button-text-md)',
gap: 'gap-(--button-gap-md)',
},
}variants.ts — CVA maps props to class strings:
import { tokens } from '@/lib/utils'
import { buttonSizes } from './tokens'
export const buttonVariants = cva(
// Base classes — structural, never color
'inline-flex items-center justify-center font-medium transition-all ...',
{
variants: {
// Color expressed via semantic tokens — NOT hard-coded
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
outline: 'border border-input bg-background hover:bg-accent',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
// Size joins all class strings from the token map
size: {
md: tokens(buttonSizes, 'md'),
// ↳ "h-(--button-height-md) px-(--button-px-md) rounded-(--button-radius) ..."
},
},
}
)The tokens() helper (from lib/utils.ts) simply joins all values in a size map into a single class string — no logic, no magic.
The colour slot pattern
Some components (Badge, Alert, Input) need to express colour in two independent dimensions — what colour (variant) and how it renders (appearance). Hard-coding both would require N×M variants. Instead TOUI uses CSS variable colour slots:
The pattern in code:
// Step 1 — variant sets the slots (arbitrary CSS var inline style via Tailwind)
export const badgeColors = {
success: {
bg: '[--badge-bg:var(--color-success)]',
text: '[--badge-text:var(--color-success-foreground)]',
},
primary: {
bg: '[--badge-bg:var(--color-primary)]',
text: '[--badge-text:var(--color-primary-foreground)]',
},
}// Step 2 — appearance consumes the slots
appearance: {
default: 'bg-(--badge-bg) text-(--badge-text)',
light: [
'bg-[color-mix(in_oklch,var(--badge-bg)_20%,transparent)]',
'text-[--badge-text-light,var(--badge-bg)]',
].join(' '),
outline: [
'bg-transparent',
'border-[color-mix(in_oklch,var(--badge-bg)_60%,transparent)]',
'text-[--badge-text-light,var(--badge-bg)]',
].join(' '),
}The result: 6 variants × 4 appearances = 24 combinations from 10 class strings. Switching the active theme automatically re-paints all 24 because --badge-bg points to --color-primary, which [data-theme="rose"] overrides.
Overriding a single component's tokens
Because all sizing lives in CSS variables, you can tune any component without touching its source. All three approaches below work:
Option A — CSS override in globals.css:
/* Make buttons pill-shaped and taller everywhere */
:root {
--button-radius: 9999px;
--button-height-md: calc(var(--spacing) * 11);
}Option B — DTCG token (rebuilt via Style Dictionary):
{
"button": {
"radius": { "$type": "dimension", "$value": "9999px" },
"height": {
"md": { "$type": "dimension", "$value": "calc(var(--spacing) * 11)" }
}
}
}Then run npm run tokens:build.
Option C — Inline style on a single instance:
// Override for one specific button only
<Button style={{ '--button-radius': '9999px' } as React.CSSProperties}>
Pill button
</Button>Scoping overrides to a theme
Component-level overrides can be scoped to a theme by adding them to that theme's CSS file:
[data-theme='orange'] {
/* Global shape */
--radius: 1.5rem;
/* Button-specific — even more exaggerated pill */
--button-radius: 9999px;
--button-height-md: calc(var(--spacing) * 10);
--button-px-md: calc(var(--spacing) * 6);
}This means the orange theme applies different shape rules to buttons than the global --radius would suggest — giving each theme the ability to fine-tune individual components without affecting others.
Component token reference
Every component exposes its tokens as --{component}-{property}-{size}. The full list compiled into :root by npm run tokens:build:
| Component | Key tokens |
|---|---|
button | --button-height-{xs–xl}, --button-px-{xs–xl}, --button-radius, --button-gap-{xs–xl} |
badge | --badge-height-{sm–lg}, --badge-px-{sm–lg}, --badge-radius, --badge-text-{sm–lg} |
input | --input-height-{sm–lg}, --input-px-{sm–lg}, --input-radius |
card | --card-radius, --card-padding |
avatar | --avatar-size-{xs–xl}, --avatar-radius |
tabs | --tabs-height, --tabs-radius |
select | --select-height-{sm–lg}, --select-px-{sm–lg}, --select-radius |
toast | --toast-radius, --toast-padding |
All component tokens are defined in tokens/components/*.json and resolve through Tier 2 semantic vars — so global theme changes cascade automatically unless you explicitly override at component level.
Token DTCG Format
Tokens are authored in DTCG JSON format. The $extensions.mode.dark key carries the dark mode value in the same file:
{
"primary": {
"$type": "color",
"$value": "oklch(0.205 0 0)",
"$description": "Main brand color — near-black in light mode",
"$extensions": {
"mode": {
"dark": "oklch(0.985 0 0)"
}
}
},
"component-height-md": {
"$type": "dimension",
"$value": "calc(var(--spacing) * 9)"
}
}Style Dictionary's value/passthrough transform preserves var(), calc(), and oklch() expressions as-is in the CSS output.
Extending the System
The theming system is designed to be extended at every layer. Below are the most common extension patterns.
1. New semantic color roles
Add a token to tokens/semantic/colors.json when you need a new role that multiple components should share — for example a brand accent or a surface-elevated tier.
{
"brand": {
"$type": "color",
"$value": "oklch(0.55 0.22 300)",
"$description": "Secondary brand accent — purple",
"$extensions": { "mode": { "dark": "oklch(0.68 0.20 300)" } }
},
"brand-foreground": {
"$type": "color",
"$value": "oklch(0.985 0 0)",
"$extensions": { "mode": { "dark": "oklch(0.985 0 0)" } }
}
}Run npm run tokens:build. Style Dictionary writes --brand and --brand-foreground to :root and .dark, and maps --color-brand in @theme inline so Tailwind generates bg-brand, text-brand-foreground automatically.
2. New component size steps
The built-in scale goes from 2xs to 2xl. If you need a 3xl step, add it to tokens/semantic/scale.json:
{
"component": {
"height": {
"3xl": {
"$type": "dimension",
"$value": "calc(var(--spacing) * 14)"
}
},
"px": {
"3xl": {
"$type": "dimension",
"$value": "calc(var(--spacing) * 10)"
}
}
}
}After rebuild, --component-height-3xl and --component-px-3xl are available. You can also expose Tailwind shorthand utilities by extending the @utility blocks in styles/globals.css:
/* The existing pattern — adding 3xl just works automatically */
@utility hc-* {
height: --value(--component-height-*);
}No CSS change needed — Tailwind's --value(--component-height-*) pattern already matches any new --component-height-X variable.
3. Per-component token overrides
Each component can have its own token file under tokens/components/. These are the highest specificity in the token chain and let you tune a single component without touching global scale values.
{
"button": {
"height": {
"md": {
"$type": "dimension",
"$value": "calc(var(--spacing) * 10)"
}
},
"radius": {
"md": {
"$type": "dimension",
"$value": "var(--radius-full)"
}
}
}
}After rebuild, --button-height-md and --button-radius-md override the semantic defaults only for buttons. The component's tokens.ts file reads these:
export const buttonTokens = cva('', {
variants: {
size: {
md: 'h-(--button-height-md) px-(--button-px-md) rounded-(--button-radius-md)',
},
},
})4. Additional theme axes
Beyond color themes you can add entirely new axes — for example a density axis that tightens or loosens spacing independent of color:
[data-density='compact'] {
--component-height-md: calc(var(--spacing) * 7);
--component-height-lg: calc(var(--spacing) * 8);
--component-px-md: calc(var(--spacing) * 3);
--component-gap-md: calc(var(--spacing) * 1.5);
}
[data-density='comfortable'] {
--component-height-md: calc(var(--spacing) * 11);
--component-height-lg: calc(var(--spacing) * 12);
--component-px-md: calc(var(--spacing) * 5);
--component-gap-md: calc(var(--spacing) * 2.5);
}Set it in parallel to the color theme:
document.documentElement.setAttribute('data-density', 'compact')
document.documentElement.setAttribute('data-theme', 'blue')
// <html class="dark" data-theme="blue" data-density="compact">Because each axis uses a different attribute the three axes — dark/light · color theme · density — never interfere with each other.
5. Brand-level token override (white-labelling)
For multi-tenant or white-label products, map the entire brand at the CSS level. Override :root at the tenant level instead of touching any JSON:
/* Loaded dynamically per tenant */
:root {
/* Replace the default near-black primary with Acme blue */
--primary: oklch(0.50 0.24 255);
--primary-foreground: oklch(0.985 0 0);
--ring: oklch(0.50 0.24 255);
/* Acme uses a completely rounded style */
--radius: 9999px;
/* Acme brand font (loaded separately) */
--font-sans: 'Acme Sans', sans-serif;
}
.dark {
--primary: oklch(0.65 0.22 255);
}Import it after tokens.css so it wins specificity without touching any token file.
6. Extending Style Dictionary
sd.config.mjs controls how tokens map to CSS blocks. The getBlock() router determines where each token lands. To add a new output section — say a @media print {} block for print-safe colors — register a new format and add a case to getBlock():
// 1. Add a block type in getBlock()
if (fp.includes('tokens/print')) return 'print'
// 2. Emit it in the css/token-system/vars format
case 'print':
printVars.push(` ${cssName}: ${value};`)
break
// 3. Wrap the collected vars
const printBlock = printVars.length
? `@media print {\n :root {\n${printVars.join('\n')}\n }\n}`
: ''7. Figma token sync
The pipeline already outputs tokens/output/figma.json in Token Studio format. To keep Figma variables in sync with code:
- Open the Token Studio plugin in Figma
- Load from file → select
tokens/output/figma.json - Run
npm run tokens:buildafter any token edit - Re-import the updated JSON into Figma
For automated sync, add a CI step that uploads figma.json to Figma via the Variables REST API after every merge to main.