Design System Tokens with Tailwind v4's @theme inline
Design System Tokens with Tailwind v4's @theme inline
One CSS file. CSS variables for runtime theming. @theme inline to make them Tailwind utilities. The bridge that makes semantic tokens work in both contexts.
Tailwind v4 introduced @theme inline — a way to map CSS custom properties directly to Tailwind utilities without a tailwind.config.js. kui-react uses this to build a two-layer token system: semantic CSS variables as the source of truth, Tailwind utility classes as the ergonomic API.
This post walks through the exact structure in app/globals.css and explains why each choice was made.
The Problem with Hardcoded Values
The naive approach to theming: define two sets of Tailwind config values, one for light and one for dark. Add a dark: variant to every color class. The result is bg-white dark:bg-gray-900 everywhere — coupling the component to a specific palette rather than to a semantic role.
When a client wants a third theme, or brand colors change, every component needs updating.
Semantic tokens invert this: components reference bg-surface-base and text-primary. The mapping from semantic name to actual color lives in one place. Swap the mapping, every component updates.
Layer 1: CSS Variables
:root {
/* Surface elevations */
--surface-base: #ffffff;
--surface-raised: #f8f9fa;
--surface-overlay: #ffffff;
--surface-sunken: #f1f3f5;
/* Text roles */
--text-primary: #1a1b1e;
--text-secondary: #495057;
--text-disabled: #adb5bd;
--text-inverse: #ffffff;
/* Brand */
--primary: #228be6;
--primary-subtle: #e7f5ff;
--primary-fg: #ffffff;
/* State colors */
--success: #2f9e44;
--success-subtle: #ebfbee;
--success-fg: #ffffff;
--error: #e03131;
--error-subtle: #fff5f5;
--error-fg: #ffffff;
--warning: #e67700;
--warning-subtle: #fff9db;
--warning-fg: #ffffff;
/* Borders */
--border-default: #dee2e6;
--border-focus: #228be6;
}
.dark {
--surface-base: #1a1b1e;
--surface-raised: #25262b;
--surface-overlay: #2c2e33;
--surface-sunken: #141517;
--text-primary: #f8f9fa;
--text-secondary: #ced4da;
--text-disabled: #5c5f66;
--text-inverse: #1a1b1e;
--primary: #4dabf7;
--primary-subtle: #1c3a5e;
--primary-fg: #1a1b1e;
--success: #69db7c;
--success-subtle: #1a2f1e;
--success-fg: #1a1b1e;
--error: #ff6b6b;
--error-subtle: #2f1a1a;
--error-fg: #1a1b1e;
--border-default: #373a40;
--border-focus: #4dabf7;
}
Four surface elevations model depth: base is the page background, raised is cards, overlay is modals and dropdowns, sunken is inset areas like code blocks and input backgrounds. The -subtle / -fg pattern for state colors is consistent across all semantic roles — --success as the accent, --success-subtle as a pale background tint, --success-fg as text on top of the base color.
Layer 2: @theme inline
@theme inline {
--color-surface-base: var(--surface-base);
--color-surface-raised: var(--surface-raised);
--color-surface-overlay: var(--surface-overlay);
--color-surface-sunken: var(--surface-sunken);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-disabled: var(--text-disabled);
--color-text-inverse: var(--text-inverse);
--color-primary: var(--primary);
--color-primary-subtle: var(--primary-subtle);
--color-primary-fg: var(--primary-fg);
--color-success: var(--success);
--color-success-subtle: var(--success-subtle);
--color-success-fg: var(--success-fg);
--color-error: var(--error);
--color-error-subtle: var(--error-subtle);
--color-error-fg: var(--error-fg);
--color-border-default: var(--border-default);
--color-border-focus: var(--border-focus);
}
Tailwind v4 reads --color-* variables from @theme inline and generates utility classes: bg-surface-base, text-primary, border-border-focus, text-success-fg. The var(--surface-base) reference means these utilities resolve at runtime — not baked into the stylesheet at build time.
This is the bridge. Without @theme inline, you'd have CSS variables usable in arbitrary CSS but no Tailwind utilities. With it, both work simultaneously.
Usage in Components
// Button.tsx
<button className="bg-primary text-primary-fg hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-border-focus" />
// Alert.tsx
<div className="bg-error-subtle text-error border border-error/30" />
// Card.tsx
<div className="bg-surface-raised shadow-sm text-text-primary" />
No hardcoded hex values. No dark: variants. Theme switching happens by toggling the dark class on <html> — the CSS variables update, and every component using those Tailwind utilities updates automatically.
Prose Styles Without @tailwindcss/typography
The globals file handles rich text editor content without the official Typography plugin:
.kui-rte-content h1 {
font-size: 1.875rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.75rem;
}
.kui-rte-content p {
color: var(--text-secondary);
margin-bottom: 1rem;
line-height: 1.75;
}
.kui-rte-content code {
background: var(--surface-sunken);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
This avoids the @tailwindcss/typography dependency and its opinionated reset behavior while still using CSS variables for theming. The prose content inherits the current theme automatically.
Scrollbar Hover Pattern
.sidebar-scrollbar-hover {
scrollbar-width: none;
}
.sidebar-scrollbar-hover::-webkit-scrollbar {
width: 0;
}
.sidebar-scrollbar-hover:hover {
scrollbar-width: thin;
scrollbar-color: var(--border-default) transparent;
}
.sidebar-scrollbar-hover:hover::-webkit-scrollbar {
width: 6px;
}
.sidebar-scrollbar-hover:hover::-webkit-scrollbar-thumb {
background-color: var(--border-default);
border-radius: 3px;
}
Scrollbars hidden by default, visible on hover. Uses semantic --border-default so it adapts to light/dark. The scrollbar-width: thin (Firefox) and ::-webkit-scrollbar (Chrome/Safari) target both rendering engines.
Trade-off
@theme inline is Tailwind v4 only. Projects on v3 need tailwind.config.js's extend.colors instead. The CSS variable → config token pattern is the same; the bridge mechanism differs.
Runtime CSS variables mean the stylesheet always includes the variable references — Tailwind's purging can't eliminate them at build time. Tailwind v4's JIT compilation mitigates this, but colors aren't fully dead-code-eliminated the way static values would be.
Adding a new token requires two additions: the CSS variable in :root + .dark, and the @theme inline mapping. This is intentional — you can't create a Tailwind utility for a token without also defining its dark-mode value.
Business Impact
A client wants brand color swaps for a white-label version of the app. With hardcoded Tailwind palette values, that's a find-and-replace across hundreds of component files. With semantic tokens, it's updating a dozen CSS variable values in globals.css.
This isn't hypothetical. Every project with multiple clients, environments, or anticipated rebranding eventually hits this. Building the token layer upfront is far cheaper than retrofitting it later.
Something to Try
If you're on Tailwind v4, add three semantic variables to your globals.css: --surface-base, --text-primary, --primary. Define their dark-mode values in .dark. Map them with @theme inline. Then find one component that uses bg-white dark:bg-gray-900 and replace it with bg-surface-base. Run the dark mode toggle. The pattern becomes obvious immediately.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox