Design tokens are the atomic building blocks of a visual design system — named values that capture decisions about color, spacing, typography, shadows, and motion. Rather than scattering hex codes and pixel values throughout a codebase, design tokens centralize these decisions into a single layer of abstraction. When implemented as CSS custom properties (variables), tokens become runtime-mutable, inheritable, and composable, enabling features like dark mode, theming, and white-label customization without rebuilding stylesheets.
A well-architected token system has at least two tiers: primitive tokens that define the raw palette (--color-blue-500: #3b82f6) and semantic tokens that assign meaning (--color-primary: var(--color-blue-500)). The internal component library takes this further with a comprehensive three-tier system: primitives define brand and neutral color scales, semantic tokens map those to UI roles (background, foreground, border, ring), and component tokens provide scoped overrides for individual components. Dark mode is handled by redefining semantic tokens inside a .dark class or prefers-color-scheme media query — every component automatically adapts because it references semantic tokens, not raw values.
CSS custom properties are uniquely suited to this role because they participate in the cascade, can be scoped to any selector, and are resolved at computed-value time. Unlike Sass variables or JavaScript theme objects, CSS variables work without a build step, respect specificity and inheritance, and can be modified at runtime via JavaScript. This makes them the natural choice for design token architecture in modern web applications.
Primitive tokens define the raw value palette — the complete set of colors, sizes, and typographic scales available to the system. They are named by what they are, not what they do: --color-slate-900, --spacing-4, --font-size-sm. Primitives rarely change and form the foundation that semantic tokens reference. A typical system defines 10-12 shades per color family using a numbered scale (50 through 950).
Semantic tokens assign purpose to primitive values: --color-background: var(--color-white), --color-foreground: var(--color-slate-900), --color-destructive: var(--color-red-500). Components reference only semantic tokens, never primitives. This indirection is what makes theming possible — switching from light to dark mode means reassigning 15-20 semantic tokens rather than updating hundreds of component styles.
CSS custom properties follow the cascade: a variable defined on :root is available everywhere, but can be overridden on any descendant element. This enables scoped theming — a .sidebar can redefine --color-background to a darker shade, and all components inside the sidebar automatically pick up the override. This cascade-based scoping is impossible with Sass variables or JS theme objects, which resolve at build time.
Dark mode is not a separate stylesheet or a CSS filter — it is a reassignment of semantic tokens. The same --color-background token that resolves to #ffffff in light mode resolves to #0f1117 in dark mode. Components are dark-mode-compatible by default because they only reference semantic tokens. The switch happens in a single CSS rule that redefines 15-20 custom properties.
The set of semantic token names is a contract between the design system and its consumers. Every component assumes that --background, --foreground, --primary, --border, and --ring exist. Adding a new token to the contract requires updating every theme, removing one breaks every component that references it. Treat the semantic token list like a public API — versioned, documented, and changed deliberately.
The internal component library organizes tokens into three layers. Primitive tokens define the palette, semantic tokens assign meaning, and component tokens provide localized overrides.
/* === Primitive Tokens === */
:root {
/* Brand colors */
--color-brand-50: #eef2ff;
--color-brand-100: #e0e7ff;
--color-brand-500: #6c63ff;
--color-brand-600: #5a52e0;
--color-brand-900: #2e1065;
/* Neutral scale */
--color-slate-50: #f8fafc;
--color-slate-100: #f1f5f9;
--color-slate-200: #e2e8f0;
--color-slate-700: #334155;
--color-slate-800: #1e293b;
--color-slate-900: #0f172a;
--color-slate-950: #020617;
/* Status colors */
--color-green-500: #22c55e;
--color-red-500: #ef4444;
--color-amber-500: #f59e0b;
}
/* === Semantic Tokens (Light Mode) === */
:root {
--background: var(--color-slate-50);
--foreground: var(--color-slate-900);
--card: #ffffff;
--card-foreground: var(--color-slate-900);
--primary: var(--color-brand-500);
--primary-foreground: #ffffff;
--secondary: var(--color-slate-100);
--secondary-foreground: var(--color-slate-900);
--muted: var(--color-slate-100);
--muted-foreground: var(--color-slate-700);
--accent: var(--color-brand-50);
--accent-foreground: var(--color-brand-900);
--destructive: var(--color-red-500);
--destructive-foreground: #ffffff;
--border: var(--color-slate-200);
--input: var(--color-slate-200);
--ring: var(--color-brand-500);
--radius: 0.5rem;
}
/* === Semantic Tokens (Dark Mode) === */
.dark {
--background: var(--color-slate-950);
--foreground: var(--color-slate-50);
--card: var(--color-slate-900);
--card-foreground: var(--color-slate-50);
--primary: var(--color-brand-500);
--primary-foreground: #ffffff;
--secondary: var(--color-slate-800);
--secondary-foreground: var(--color-slate-50);
--muted: var(--color-slate-800);
--muted-foreground: var(--color-slate-200);
--accent: var(--color-slate-800);
--accent-foreground: var(--color-slate-50);
--destructive: var(--color-red-500);
--border: var(--color-slate-700);
--input: var(--color-slate-700);
--ring: var(--color-brand-600);
}
Tailwind v4 natively supports CSS custom properties as theme values. The token system maps semantic tokens to Tailwind utility classes, so bg-background resolves to var(--background) and automatically adapts to dark mode.
/* tailwind.config.ts */
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
};
export default config;
Because CSS custom properties are resolved at runtime, theme switching requires only toggling a class on the document element. No stylesheet reload, no React re-render tree, no Flash of Unstyled Content.
// Theme toggle implementation
function toggleTheme() {
const root = document.documentElement;
const isDark = root.classList.contains("dark");
if (isDark) {
root.classList.remove("dark");
localStorage.setItem("theme", "light");
} else {
root.classList.add("dark");
localStorage.setItem("theme", "dark");
}
}
// Initialize theme from user preference
function initTheme() {
const stored = localStorage.getItem("theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (stored === "dark" || (!stored && prefersDark)) {
document.documentElement.classList.add("dark");
}
}
// React hook for theme state
function useTheme() {
const [theme, setTheme] = React.useState<"light" | "dark">(() => {
if (typeof window === "undefined") return "light";
return document.documentElement.classList.contains("dark") ? "dark" : "light";
});
const toggle = React.useCallback(() => {
const next = theme === "dark" ? "light" : "dark";
document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", next);
setTheme(next);
}, [theme]);
return { theme, toggle };
}
The internal component library implements a comprehensive semantic color system with brand colors (the purple #6c63ff accent used across the search products), status colors (success green, destructive red, warning amber), and neutral grays. Every component in the registry — from Buttons to SearchBars to AnswerPanels — references only semantic tokens. This means a single globals.css update can rebrand the entire component library.
The merchant dashboard uses this token architecture to support multiple visual themes for different merchant-facing contexts. The storefront widget uses a compact token set that blends with the merchant’s existing site colors — merchants can override --primary and --background to match their brand without touching component source code. The admin dashboard uses the full brand theme with dark mode enabled by default for reduced eye strain during extended use.
The documentation site leverages scoped token overrides for its component preview panels. Each preview is wrapped in a container that redefines tokens to show the component in both light and dark contexts side-by-side. The same component instance renders differently in each container because CSS custom property inheritance scopes the token values to the container boundary.
A common pattern stores token values as HSL channel values (without the hsl() wrapper) so that opacity can be applied via Tailwind’s opacity modifier syntax.
/* Store as HSL channels only */
:root {
--primary: 245 58% 51%; /* hue saturation lightness */
--primary-foreground: 0 0% 100%;
}
/* Tailwind resolves to hsl() automatically */
/* bg-primary → hsl(245 58% 51%) */
/* bg-primary/50 → hsl(245 58% 51% / 0.5) */
/* text-primary/80 → hsl(245 58% 51% / 0.8) */
/* This works because Tailwind wraps the value: */
/* bg-primary → background-color: hsl(var(--primary)); */
/* bg-primary/50 → background-color: hsl(var(--primary) / 0.5); */
Components can define their own tokens that override the global semantic tokens within their scope. This is useful for complex components that need internal color relationships.
/* Component-scoped tokens */
.search-bar {
--search-bg: var(--card);
--search-border: var(--border);
--search-text: var(--foreground);
--search-placeholder: var(--muted-foreground);
--search-highlight: var(--primary);
}
/* Focused state overrides the component tokens */
.search-bar:focus-within {
--search-border: var(--ring);
--search-bg: var(--background);
}
/* Internal elements reference component tokens */
.search-bar__input {
background: var(--search-bg);
border-color: var(--search-border);
color: var(--search-text);
}
.search-bar__input::placeholder {
color: var(--search-placeholder);
}
.search-bar__result--active {
background: hsl(var(--search-highlight) / 0.1);
border-left: 2px solid hsl(var(--search-highlight));
}
var(--color-blue-500) instead of var(--primary)) defeats the purpose of the token system. Dark mode and theming break because primitives don’t change — only semantic tokens are reassigned. Every component style should reference semantic tokens exclusively.--button-hover-bg, --card-title-color, --sidebar-border-width) creates a token explosion that is harder to maintain than the scattered values it replaced. Keep the semantic layer to 15-25 tokens that cover roles (background, foreground, primary, muted, border), not components.initial, not to a sensible default. Use var(--primary, #6c63ff) fallbacks in component libraries that might be used without the full token set, but avoid them in application code where missing tokens should fail visibly during development.bg-primary/50 opacity syntax.