CSS Variables: The Only Real Source of Truth
CSS custom properties own the actual values. Every other layer references them.
The case for CSS variables is runtime manipulation. When SeedFlip applies a new design seed, it writes new values to :root with a single JavaScript call. The entire page — fonts, colors, shadows, radius — updates in under 16ms without touching the DOM beyond the root element. That's only possible because every style in the product is a var() reference, not a hardcoded value.
:root {
--bg: #0a0a0a;
--surface: #141414;
--surface-hover: #1c1c1c;
--border: rgba(255, 255, 255, 0.07);
--text: #f2f2f2;
--text-muted: rgba(242, 242, 242, 0.45);
--accent: #fb923c;
--accent-soft: rgba(251, 146, 60, 0.10);
--radius: 5px;
--radius-sm: 3px;
--radius-lg: 10px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.45);
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.3);
--font-heading: "Space Grotesk", sans-serif;
--font-body: "Inter", sans-serif;
}CSS variables win when: you need runtime theming, dark/light mode switching, user-configurable color preferences, or any scenario where values change after page load. They also win when you have non-Tailwind contexts — vanilla CSS components, CSS-in-JS, SVG styling — that need access to the same tokens.
CSS variables fail when: you want to generate utility classes from token values. You can write bg-[var(--accent)] in Tailwind using arbitrary value syntax, but it doesn't generate a named utility like bg-accent. For that, you need the Tailwind config.
Tailwind Config: The Utility Mapping Layer
Tailwind's job is generating atomic utility classes from your design tokens. It does not own the values. It maps to them.
The mistake developers make: hardcoding hex values in the Tailwind config.
// WRONG — values live here, breaks runtime theming
colors: {
accent: '#fb923c', // hardcoded
}
// CORRECT — references CSS variables
colors: {
bg: 'var(--bg)',
surface: 'var(--surface)',
'surface-hover': 'var(--surface-hover)',
border: 'var(--border)',
text: 'var(--text)',
'text-muted': 'var(--text-muted)',
accent: 'var(--accent)',
'accent-soft': 'var(--accent-soft)',
},
borderRadius: {
DEFAULT: 'var(--radius)',
sm: 'var(--radius-sm)',
lg: 'var(--radius-lg)',
},
boxShadow: {
DEFAULT: 'var(--shadow)',
sm: 'var(--shadow-sm)',
}Now bg-accent generates as background-color: var(--accent). The utility class exists. The value lives in CSS. Runtime theming still works because the utility is a reference, not a value.
Tailwind config wins when: your team writes JSX and expects utility classes, you're using Tailwind's responsive and state variants (hover:bg-accent, md:text-text-muted), or you want autocomplete in your IDE.
Tailwind config fails when: you need values accessible outside of class attributes — in CSS files, SVG, Canvas, or anything that can't consume Tailwind utilities. It also fails when you use it as the source of truth. The config is a map. The territory is your CSS variables.
shadcn Theme: The Component Consumer
shadcn/ui is built entirely on CSS custom properties. Every component — Button, Card, Dialog, Select — reads from a specific set of variable names it expects to find in :root. The theme layer is your translation between your token names and shadcn's expected names.
/* Your design tokens → shadcn's expected naming */
:root {
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
--card-foreground: var(--text);
--popover: var(--surface);
--popover-foreground: var(--text);
--primary: var(--accent);
--primary-foreground: #0a0a0a; /* calculate from luminance */
--secondary: var(--surface-hover);
--secondary-foreground: var(--text);
--muted: var(--surface);
--muted-foreground: var(--text-muted);
--accent: var(--accent-soft); /* shadcn's --accent ≠ your --accent */
--accent-foreground: var(--text);
--border: var(--border);
--input: var(--border);
--ring: var(--accent);
--radius: var(--radius);
}Note the collision: shadcn's --accent variable is used for hover backgrounds on secondary elements — not for your primary accent color, which maps to shadcn's --primary. If you set --accent: var(--accent) here, you're creating a circular reference. shadcn's naming was designed around their default palette, not around the concept of “one main accent color.”
shadcn theme wins when: you're building a Next.js app with shadcn components and want Button, Dialog, Popover, and every other component to pick up your tokens automatically without per-component overrides.
shadcn theme fails when: you're not using shadcn components. Adding the mapping layer with no shadcn consumers is pointless overhead. It also fails as a standalone approach — if you define --primary in shadcn's namespace without a base :root token block, you've duplicated values and broken the single source of truth.
The Decision Framework
1. DEFINE in CSS Variables (:root)
→ Source of truth. All hex values live here.
→ Runtime-mutable.
2. MAP in Tailwind Config (tailwind.config.js)
→ Reference CSS variables with var().
→ Never hardcode values.
→ Enables utility classes for JSX authoring.
3. TRANSLATE in shadcn globals.css
→ Map your token names → shadcn's expected names.
→ One-time setup. Only if using shadcn components.
4. CONSUME in components
→ Use Tailwind utilities: bg-accent, text-text-muted
→ Or CSS custom properties: var(--accent)
→ Never hardcode hex values in components.If you're not using shadcn, skip layer 3. If you're not using Tailwind, skip layer 2. Layer 1 is non-negotiable regardless of stack.
How SeedFlip's Architecture Exposes the Hierarchy
SeedFlip's real-time flip mechanic only works because the product is built on this exact architecture. When you hit Shuffle, one JavaScript function writes new values to :root. The page reskins instantly — new fonts load via Google Fonts API, new colors propagate through every var() reference, shadows and radius update across every component. No class toggling. No DOM traversal. No React re-render for styling.
That's what correct CSS variable architecture looks like at runtime. The demo page is the proof of concept for every article arguing that CSS variables should own the values.
The DNA export (free tier) gives you the :root block — layer 1, ready to paste. The Tailwind DNA (Pro) gives you the Tailwind config with var() references — layer 2. The shadcn theme export (Pro) gives you the globals.css mapping — layer 3. All three generated from the same seed, consistent with each other, reflecting the exact hierarchy described above.
Use Lock & Remix to lock the tokens you're committed to and shuffle the ones you're still deciding on. Lock Palette, shuffle Type. Lock Shape, shuffle Atmosphere. The exports always reflect the active state — all three layers in sync regardless of how many remix cycles it took to get there.
Browse The Archive, find the aesthetic, export the full stack.