Getting started

Theming

How tokens resolve, and where to change them.

The token layer has three stages, and the order they load in is not arbitrary.

design-system/index.css
@import "./tokens/color.css";   /* 1. primitives register in Tailwind's namespaces */
@import "./themes/base.css";    /* 2. the semantic layer resolves them to roles   */
@import "./tokens/themes.css";  /* 3. the bridge maps roles back to utilities     */

Never use a primitive directly

A component asks for a role, not a value. bg-surface-sunken, not bg-neutral-100; text-text-accent, not text-[#F4521B]. The role is what survives a theme change — the value is what a theme is allowed to move.

PreferOver
bg-surface, bg-surface-raised, bg-surface-sunkenbg-white, bg-gray-50
text-text-primary, text-text-secondary, text-text-mutedtext-black, text-gray-500
border-border-subtle, border-border-strongborder-gray-200
text-text-accenttext-[#F4521B]

Products differ by theme, not by component

A product that needs to look different changes themes/<product>.css and nothing else. If you find yourself editing a component to make one app look different, the theme layer is missing something — that gap is the bug, not the component.

<html data-theme="admin">        {/* light */}
<html data-theme="admin" class="dark">  {/* dark */}

Dark mode is a block inside each theme file, guarded so source order can't make the light block outrank it.

Strict mode

Once an app has finished migrating, you can delete Tailwind's default scales entirely, so bg-blue-500 and text-sm stop resolving:

@import "tailwindcss";
@import "../design-system/tokens/strict.css";  /* between the two */
@import "../design-system/index.css";

The build then fails on anything left behind, which is the point. If the failure list is long, that list is the ad hoc styling the migration was for — work through it rather than disabling the guard.

Adding a token

Two files, always: the CSS variable in tokens/, and its description in lib/tokens.ts. Every swatch and table in these docs is generated from the second one, so a token without a description shows up here as a blank.

On this page