Getting Started

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:

  1. Single source of truth — all design decisions live in DTCG-format JSON under tokens/
  2. CSS custom properties everywhere — no hard-coded values in components, everything is a var(--...)
  3. Orthogonal axes — color theme (data-theme) and light/dark mode (.dark class) 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.

Loading
TierSourceOutputPurpose
1 — Basetokens/base/**:rootRaw palette values, brand settings, motion curves
2 — Semantictokens/semantic/**:root + .dark + @themeContextual roles: --primary, --background, --radius, --component-height-md
3 — Componenttokens/components/**:rootPer-component defaults that point to semantic tokens

How it builds

The pipeline runs via Style Dictionary v5:

npm run tokens:build

Outputs two files:

  • tokens/output/tokens.css:root {} + .dark {} — pure CSS custom properties consumed at runtime
  • tokens/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.

Loading

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.

Loading

The scale table

Tokenxssmmdlgxl
--component-height-*24px32px36px40px44px
--component-px-*8px12px16px20px24px
--component-radius-*--radius-sm--radius-md--radius-lg--radius-xl--radius-2xl
--component-gap-*4px6px8px12px16px

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" /> // gap

Base Styles Reference

Loading

Base Colors Reference

Loading

Dark Mode

Dark mode is handled by next-themes toggling the .dark class on <html>. All .dark overrides live in tokens.css.

Setup

app/layout.tsx
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>
  )
}
components/providers.tsx
'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:

styles/globals.css
@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.

Loading

How it works

Each theme overrides only the tokens that differ from the default theme:

tokens/output/themes/rose.css
[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--radiusHeightsPaddingPersonality
default0.625rem36px md16px mdNeutral · balanced
rose0.75rem38px md (+)16px mdSoft · rounded
blue0.25rem36px md12px md (−)Sharp · minimal
green1rem40px md (+)20px md (+)Spacious · open
orange1.5rem36px md20px md (+)Pill · playful

Applying a theme at runtime

components/layout/theme-switcher.tsx
// 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:

app/layout.tsx
<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

styles/globals.css
@import "../tokens/output/themes/my-theme.css";

Register in the theme registry

lib/themes.ts
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:

styles/globals.css
@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:build

Theming 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:

Loading

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:

Loading

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:

registry/default/ui/button/tokens.ts
// 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:

registry/default/ui/button/variants.ts
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:

Loading

The pattern in code:

registry/default/ui/badge/tokens.ts
// 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)]',
  },
}
registry/default/ui/badge/variants.ts
// 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:

styles/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):

tokens/components/button.json
{
  "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:

tokens/output/themes/orange.css
[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:

ComponentKey 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:

tokens/semantic/colors.json
{
  "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.

tokens/semantic/colors.json
{
  "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:

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:

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.

tokens/components/button.json
{
  "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:

registry/default/ui/button/tokens.ts
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:

tokens/output/themes/density-compact.css
[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:

styles/brand/acme.css
/* 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():

sd.config.mjs
// 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:

  1. Open the Token Studio plugin in Figma
  2. Load from file → select tokens/output/figma.json
  3. Run npm run tokens:build after any token edit
  4. 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.

We use cookies

We use cookies to ensure you get the best experience on our website. For more information on how we use cookies, please see our cookie policy.

By clicking Accept, you agree to our use of cookies.
Learn more.