Design Tokens and Theming
The previous article in this series, "Building a Design System," covered the shape of a design system: components, variants, and the discipline of building a shared vocabulary instead of one-off UI per feature. That article treated colors and spacing as settled facts, values you plug into a component and move on. This one asks where those values actually live, because the answer determines whether your product can support a second brand, a dark theme, or a white-label customer without a rewrite.
This is part of the React & Next.js Complete Guide, and it's about design tokens: the layer between "someone picked a color in Figma" and "a component renders that color in the browser." Done well, tokens are boring infrastructure you barely think about. Done poorly, every rebrand or theme request turns into a multi-week hunt through components for hardcoded hex values.
I'd treat this as something to get right early. Tokens themselves aren't hard to build. The real cost shows up later, when you retrofit them onto a component library that already has colors and spacing baked in everywhere, and that kind of cleanup is the first thing to get deprioritized once the product is shipping features.
What a design token actually is
A design token is a named value that stands in for a raw design decision: a color, a spacing unit, a font size, a shadow, a border radius. Instead of a button component containing #3b5bfd, it references a token called color.brand.primary, and that token resolves to #3b5bfd wherever it's defined.
The indirection is the entire point. When the brand color changes, or a new theme needs a different value for the same semantic role, you edit the token definition once instead of finding every place the raw value was used. This is the same reasoning that justifies constants over magic numbers in code, applied to design.
Tokens are usually organized in tiers:
- Global tokens (sometimes called primitive or reference tokens): raw values with no semantic meaning attached, like
blue-500: #3b82f6orspacing-4: 16px. These are the palette, not the decisions. - Semantic tokens (alias tokens): named for their role, like
color-primaryorcolor-danger, which point at a global token. A button's "primary" color and a link's "primary" color can both referencecolor-primarywithout either component knowing what the underlying hex value is. - Component tokens (sometimes): scoped to a specific component when it needs a value that diverges from the general semantic token, like
button-border-radiusdiffering from the app's generalradius-md.
Most teams don't need all three tiers on day one. A small product does fine with global and semantic tokens; component-level tokens earn their complexity only once you have enough components with genuinely divergent needs that a shared semantic token starts fighting multiple use cases.
Representing tokens in a Next.js and Tailwind stack
If you're on Tailwind, as covered in the earlier styling article in this series, the config file is already doing token work: theme.extend.colors is your semantic token layer. The missing piece for real theming is making those values swappable at runtime rather than fixed at build time.
CSS custom properties make this work. Tailwind's utilities can reference a CSS variable instead of a literal value, and that variable's actual value can change per theme without recompiling anything.
/* globals.css */
:root {
--color-background: 255 255 255;
--color-foreground: 15 23 42;
--color-primary: 59 91 253;
--color-primary-foreground: 255 255 255;
--color-border: 226 232 240;
--radius-md: 0.5rem;
}
.dark {
--color-background: 15 23 42;
--color-foreground: 241 245 249;
--color-primary: 96 122 255;
--color-primary-foreground: 15 23 42;
--color-border: 51 65 85;
}
The values are space-separated RGB channels rather than hex strings, which lets Tailwind apply opacity modifiers (bg-primary/50) using its rgb(var(--color-primary) / <alpha-value>) convention. The config then wires semantic token names to these variables:
// tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
theme: {
extend: {
colors: {
background: "rgb(var(--color-background) / <alpha-value>)",
foreground: "rgb(var(--color-foreground) / <alpha-value>)",
primary: {
DEFAULT: "rgb(var(--color-primary) / <alpha-value>)",
foreground: "rgb(var(--color-primary-foreground) / <alpha-value>)",
},
border: "rgb(var(--color-border) / <alpha-value>)",
},
borderRadius: {
md: "var(--radius-md)",
},
},
},
plugins: [],
};
export default config;
A component now writes bg-background text-foreground border-border, with zero knowledge of whether the current theme is light, dark, or a third brand variant. The token names stay stable; only the CSS variable values underneath change.
Structuring tokens as a source of truth, not scattered CSS
Hand-writing CSS variables in globals.css works for a single light/dark pair, but it stops scaling once you have more than two themes, or once design and engineering need a shared, versioned artifact instead of a file only engineers touch. This is where a token file format like Style Dictionary or the W3C Design Tokens Community Group format earns its place: tokens live as structured data (JSON), and a build step generates the CSS, Tailwind config, and even native platform outputs from that single source.
{
"color": {
"primary": {
"value": "{color.blue.500}",
"type": "color"
},
"danger": {
"value": "{color.red.600}",
"type": "color"
}
},
"spacing": {
"sm": { "value": "0.5rem", "type": "dimension" },
"md": { "value": "1rem", "type": "dimension" },
"lg": { "value": "1.5rem", "type": "dimension" }
}
}
A build tool transforms this into the CSS custom properties shown earlier, plus a Tailwind theme extension, plus whatever else you need (Swift or Kotlin constants, if a mobile app shares the design language). The tradeoff is a build step and a tool to learn. For a single web app with two themes, this is more infrastructure than the problem warrants; hand-written CSS variables are fine. Once design tokens need to be shared across a web app, a marketing site, and a mobile app, or once a design tool exports tokens directly, a structured format becomes the only way to keep multiple consumers in sync.
Multi-brand and white-label theming
The point where tokens stop being a nicety and start being a requirement is multi-tenancy: a SaaS product where different customers see their own brand color, logo, and sometimes typography, on the same underlying application.
The mechanism is the same as light/dark mode, a class or data attribute on a root element switching which set of CSS variables applies, but the values come from tenant configuration instead of a fixed set of themes baked into the CSS file.
// app/layout.tsx
import { getTenantTheme } from "@/lib/tenant";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const theme = await getTenantTheme();
return (
<html lang="en">
<body
style={
{
"--color-primary": theme.primaryColorRgb,
"--color-primary-foreground": theme.primaryForegroundRgb,
} as React.CSSProperties
}
>
{children}
</body>
</html>
);
}
Setting the variables inline via style rather than a static class means the values come from a database lookup resolved per request on the server, which avoids a flash of the default brand color before a client-side theme swap can run. That couples the root layout to a tenant-resolution call on every request, so caching that lookup (per the caching patterns covered elsewhere in this series) matters once you have more than a handful of tenants.
One real pitfall here: white-label theming multiplies your visual QA surface. A component that looks fine with the default brand blue can have contrast problems with a customer's neon green. Catching that reliably means checking against a realistic range of tenant colors, or validating programmatically with a contrast-ratio check against the WCAG minimums, rather than trusting a single design review to notice.
Dark mode as a token problem, not a component problem
It's tempting to implement dark mode by adding dark: variants scattered through components, and Tailwind's dark: prefix makes that easy to reach for. The problem is that this couples dark mode's existence to every individual component, so adding a third theme, or removing dark mode support from one surface, means touching every file that used dark:.
The alternative, and the one that scales, is that dark mode is a token problem: components reference bg-background and text-foreground, never dark:bg-slate-900, and the theme switch changes what those tokens resolve to. A component written this way has no idea dark mode exists; it just renders the semantic token, and the token's value happens to differ per theme. This is worth internalizing before writing the dedicated dark mode article later in this series, because the mechanics of avoiding a flash of incorrect theme and persisting a user's preference build directly on the token structure described here.
Key takeaways
- A design token is a named value standing in for a raw design decision. The indirection, not the naming, is what makes rebrands and theme changes a config edit instead of a codebase-wide search.
- Organize tokens in tiers: global/primitive tokens hold raw values, semantic tokens name a role (`color-primary`), and component tokens are an escape hatch used only when a component's needs genuinely diverge.
- CSS custom properties, wired into Tailwind's config, let semantic token names stay stable in components while the underlying values swap per theme at runtime.
- A structured token format (Style Dictionary or the W3C format) is worth adopting once tokens need to feed more than one consumer, like a design tool, a web app, and a mobile app. For a single app with two themes, hand-written CSS variables are enough.
- Multi-tenant or white-label theming reuses the same mechanism as dark mode, but resolving theme values per request from tenant data (and caching that lookup) matters once you have more than a handful of tenants.
- Dark mode should be a property of tokens, not a `dark:` variant scattered through every component; components should reference semantic names and stay unaware that multiple themes exist.
Frequently asked questions
What's the difference between a design token and a CSS variable?
A design token is a conceptual, named design decision; a CSS custom property is one possible implementation of it. You can also implement tokens as Sass variables, JavaScript constants, or platform-native values (Swift, Kotlin). CSS variables happen to be the right implementation choice for web theming because they can change at runtime without a rebuild.
Do I need a tool like Style Dictionary for a small project?
No. If you have one or two themes and a single web app consuming the tokens, hand-written CSS variables in a global stylesheet, wired into your Tailwind config, cover the need with far less setup. Reach for a build-tool-driven token pipeline once multiple platforms or a design tool need to share the same source of truth.
How many tiers of tokens should a typical product have?
Most products do fine with two: global tokens holding raw values, and semantic tokens naming their role. Add a component-token tier only when specific components have styling needs that a shared semantic token can't serve without compromising other components that use the same token.
Can design tokens live in a database for runtime customization?
Yes, and this is the standard approach for white-label or multi-tenant theming, where a tenant's brand color is stored data rather than a static file. Resolve it server-side per request (or from a cache) and apply it as inline CSS variables on a root element so the correct theme renders on first paint.
Does using CSS variables for theming hurt performance?
Not meaningfully. Reading a CSS custom property is a normal part of the browser's style resolution, not a JavaScript computation, so the cost is comparable to any other CSS value. The thing to actually watch is how many times you recompute or refetch the theme values themselves, not the cost of the CSS variable lookup.
How do I stop engineers from hardcoding raw hex values instead of using tokens?
Lint rules (an ESLint rule flagging raw hex or rgb values in JSX/CSS) catch most of it mechanically. The other half is making the token set genuinely sufficient for real design needs; if engineers keep reaching for raw values, it usually means a semantic token is missing, not that the team is being careless.
Further reading

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.