How to Implement a Token-Based Design System with CSS Variables and Tailwind
You can implement a token-based design system by defining RGB values as CSS custom properties in globals.css and referencing them in tailwind.config.ts using the rgb(var(--token) / <alpha-value>) syntax, enabling dynamic theming without CSS rebuilds.
This architecture creates a single source of truth for your design tokens while preserving Tailwind's utility-first workflow. The woosal1337/blog repository demonstrates this pattern by storing all color values as CSS variables that update at runtime, allowing instant theme switches with zero JavaScript bundle changes.
Defining Design Tokens as CSS Variables
Store your core palette values as CSS custom properties in app/globals.css. The implementation uses RGB triplets (space-separated numbers) rather than hex codes to enable opacity modifications via Tailwind's alpha value syntax.
:root,
.dark {
--paper: 16 16 16;
--ink: 245 245 245;
--background: 16 16 16;
--foreground: 245 245 245;
--primary: 245 245 245;
--primary-foreground: 16 16 16;
}
Using RGB triplets allows the rgb() function to accept an optional alpha channel. When Tailwind generates utility classes like bg-primary/50, the <alpha-value> placeholder receives the opacity modifier automatically.
Bridging Tokens to Tailwind Utilities
Map the CSS variables to Tailwind's color palette in tailwind.config.ts by wrapping each variable in the rgb() function with the alpha value placeholder:
colors: {
background: "rgb(var(--background) / <alpha-value>)",
foreground: "rgb(var(--foreground) / <alpha-value>)",
primary: {
DEFAULT: "rgb(var(--primary) / <alpha-value>)",
foreground: "rgb(var(--primary-foreground) / <alpha-value>)",
},
paper: "rgb(var(--paper) / <alpha-value>)",
ink: "rgb(var(--ink) / <alpha-value>)",
}
This configuration enables standard Tailwind utilities like bg-primary, text-foreground, or border-paper/20 while resolving to your CSS variable values. Because the variables live in the browser, changing them via JavaScript or CSS immediately updates every component using those utilities.
Utility-First Component Architecture
The repository uses a cn helper defined in lib/utils.tsx to safely merge Tailwind classes. This function combines clsx for conditional logic and tailwind-merge to resolve conflicting utilities:
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Components consume tokens through standard Tailwind utilities. In components/ui/button.tsx, the cva (class-variance-authority) configuration references the tokens directly:
const buttonVariants = cva(
"inline-flex items-center justify-center",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-background text-foreground border border-ink/20",
},
},
}
);
Runtime Theme Overrides
Because the system relies on CSS variables, you can override tokens for specific subtrees or components without rebuilding your stylesheet. Apply a local override by setting the variable inline:
<div
className="bg-background p-6 rounded-lg"
style={{ "--background": "240 240 240" } as React.CSSProperties}
>
<h2 className="text-foreground">Custom Background</h2>
</div>
Any component inside this container automatically inherits the new background color because Tailwind utilities resolve to rgb(var(--background)) at render time.
Summary
- Define tokens as RGB triplets in
app/globals.cssto support opacity modifiers via the/<alpha-value>syntax. - Map variables to Tailwind in
tailwind.config.tsusingrgb(var(--token) / <alpha-value>)to maintain the utility-first workflow. - Use the
cnhelper fromlib/utils.tsxto merge classes safely and prevent duplicate utility conflicts. - Override variables at runtime by setting inline styles or switching class names on parent elements, enabling instant theme changes without bundle rebuilds.
Frequently Asked Questions
Why use RGB triplets instead of hex codes for CSS variables?
RGB triplets allow the rgb() function to accept an alpha channel via Tailwind's opacity modifiers. If you stored --primary: #ffffff, you could not apply bg-primary/50 because the hex format doesn't support the / <alpha-value> interpolation that Tailwind uses. The space-separated RGB format 255 255 255 works seamlessly with rgb(var(--primary) / 0.5).
How does the cn utility prevent class conflicts?
The cn function passes inputs through clsx to flatten conditional arrays and objects, then through tailwind-merge to resolve conflicting utilities. If you pass cn("px-4", "px-6"), it returns "px-6" because tailwind-merge understands that the latter padding utility should override the former, preventing invalid CSS where multiple values compete.
Can I implement multiple themes with this token system?
Yes. Define separate scopes in app/globals.css using attribute selectors like [data-theme="blue"] or media queries like @media (prefers-color-scheme: light). Each scope reassigns the CSS variables, and all Tailwind utilities update automatically because they reference the variables dynamically rather than storing static values at build time.
How do I add a new design token to the system?
Add the variable definition to app/globals.css (e.g., --accent: 255 0 0), then register it in tailwind.config.ts under the theme.extend.colors object (e.g., accent: "rgb(var(--accent) / <alpha-value>)"). You can immediately use bg-accent, text-accent, or border-accent in your components without restarting the build process.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →