Design System Architecture
Three layers: tokens, variants, primitives, and when each layer is enough.
The Problem
Design systems fail in one of two ways. The first is configuration explosion: you build a Button with 47 props trying to cover every use case, and it becomes harder to use than building from scratch. The second is token drift: you skip the system entirely, and six months later your product has twelve slightly different blues.
Token drift is the subtler problem. It doesn't look like a bug. Each team is just doing what's fast: copying a hex value from Figma, pasting it inline. Individually rational. Collectively, a maintenance nightmare.
The demo below isn't hypothetical. This is what the primary button looks like across a real codebase after two years and three teams.
Three teams, three "primary blue" buttons. Built independently. All slightly off.
Marketing
#3b82f6Checkout
#2563ebSettings
#1d4ed8Rebrand to a new primary color? You're doing a search-and-replace across the entire codebase. Miss one and it shows in production.
The Solution: Three Layers
A maintainable design system isn't a single component library; it's three distinct layers that solve three distinct problems. Most teams build Layer 1, think they're done, and hit the wall when Layer 2 or 3 problems appear.
Layer 1
Design Tokens
Problem: Values drift across teams
Fix: CSS custom properties as the single source of truth
Layer 2
Variant System
Problem: Component APIs diverge
Fix: TypeScript-safe variant maps (CVA pattern)
Layer 3
Headless Primitives
Problem: Accessibility is hard to get right
Fix: Radix UI (or similar) for behavior, tokens for appearance
The key insight: You don't need all three layers at once. A two-person startup needs Layer 1. A team shipping a consumer product needs Layers 1 and 2. A team building an enterprise product used by screen reader users needs all three. Start at the layer that matches your current pain.
Layer 1: Design Tokens
Design tokens are the vocabulary of your visual language: colors, spacing, typography, radii, stored as CSS custom properties. The critical distinction is between primitive tokens (raw values like --blue-500: 221 83% 53%) and semantic tokens (intent-based names like --color-primary).
Semantic tokens are what components reference. Primitive tokens are what semantic tokens point to. When you rebrand, you update the pointer; all components follow automatically. When you add dark mode, you swap the semantic tokens inside a .dark selector. No conditional logic in components.
Change --primary β all components update from a single variable:
Primary button
Status badge
Focused input
Progress bar
68% complete
Token naming convention that scales:
--blue-500 β primitive: never use directly in components
--color-primary β semantic: what components reference
--color-button-bg β component token: optional, for fine-grained overrides
Layer 2: The Variant System
Tokens solve the value problem. They don't solve the API problem. Without a variant system, every team invents its own pattern: some pass type="primary", some pass isPrimary, some pass color="blue". Three buttons, three prop APIs.
The CVA pattern (class-variance-authority) solves this by making variants a first-class TypeScript concept. Every valid combination is explicit and enumerable. Storybook stories write themselves. Typos are caught at build time, not in a PR review.
variant
size
<Button variant="primary" size="md" />The configuration vs. composition trap:
A Button with 47 props is configuration explosion; it tries to handle every future use case upfront. Prefer a narrow prop surface (variant, size, loading, disabled) and let callers compose via className or asChild for the exceptions.
Layer 3: Headless Primitives
Tokens and variants handle everything visual. But some components have complex interactive behavior that is genuinely hard to implement correctly: modal dialogs need focus trapping, dropdowns need keyboard navigation, tooltips need proper ARIA relationships. Getting all of this right, and keeping it right across browser updates, is a full-time job.
Headless component libraries (Radix UI, Headless UI, Ariakit) handle the behavior layer. They provide zero styling but complete ARIA compliance and state management. Your tokens handle the rest. The separation is clean: they own accessibility, you own pixels.
For simpler interactive components, you can build the behavior yourself with proper ARIA attributes. The accordion below is fully accessible with no library: correct aria-expanded, aria-controls, and role="region". For a select dropdown with full keyboard nav and screen reader support, reach for Radix.
Accessible accordion β no library. Correct aria-expanded, aria-controls, and role="region".
Decision Matrix: Build vs Buy
The real question isn't "what's best"; it's what fits your team's current constraints. Here's how I think about the tradeoffs:
| Approach | When to use | Trade-offs |
|---|---|---|
| Roll your own (CSS + variants) | Small team, custom brand, full control | You build everything: tokens, variants, accessibility |
| shadcn/ui | Most production apps | Copy-paste components, you own the code |
| Radix UI (unstyled) | Custom brand + complex interactions | You write all styles; primitives handle behavior |
| Headless UI (Tailwind Labs) | Tailwind-heavy codebases | Smaller API surface than Radix, tightly coupled to Tailwind |
| MUI / Chakra / Mantine | Internal tools, admin panels, rapid prototyping | Opinionated styles, large bundle, theming friction |
My default recommendation: Start with shadcn/ui for most product work. It gives you Radix primitives + a solid token system + a Tailwind variant pattern, and you own every file. If your brand requirements diverge from what shadcn gives you, you already have the architecture to diverge; you just modify the files you copied.
Progressive Complexity
The same feature (a Button component) at five stages of architectural maturity. Each step solves a real problem the previous step couldn't.
Example 1: Hardcoded Styles
NaiveInline styles per-component β fast to write, impossible to maintain
The starting point most teams land at. Styles are hardcoded directly on each component. Fast to write, impossible to maintain at scale.
Why this works
When this breaks
Production Patterns
White-label products: semantic tokens pay off
I once worked on a SaaS platform that was white-labeled for 40+ enterprise clients, each with their own brand colors. The naive approach would be a per-tenant CSS file that overrides hardcoded values. With semantic tokens, it was a 10-line JSON file per client that set --color-primary, --color-secondary, and --radius-base. Zero component changes. Onboarding a new client took 20 minutes instead of a sprint.
The "asChild" pattern: polymorphic components without the headaches
A common problem: you have a Button component but need it to render as a Next.js Link. The naive fix is href prop detection and conditional rendering is messy. Radix's asChild pattern is cleaner: your Button merges its styles onto whatever child element you provide.
Structuring tokens for Figma-to-code handoff
The handoff gap kills design system adoption. Designers work in Figma with named styles. Developers implement in CSS with hex values. When the names match, Figma's Color/Primary/Default maps to --color-primary implementation becomes mechanical. Token mismatch means engineers are constantly guessing intent. Tools like Tokens Studio for Figma can sync Figma styles directly to CSS variables, but even without tooling, aligning the naming convention cuts handoff time in half.
Inheriting a codebase: audit before you build
You've joined a company with three years of accumulated hex values, three slightly different button components, and no token layer. The instinct is to propose a new design system. The right instinct is to audit first.
Grep the codebase for hardcoded color values to measure the scope of token drift. Identify which components are actually used versus which were built speculatively. Find the two or three components that touch the most screens: usually Button, Input, and a card container; those are the migration entry points. Let old and new code coexist while the product keeps shipping; deprecate, don't delete. The migration is done when old patterns stop appearing in new code, not when they've been removed from old code.
The key call: incremental token introduction versus a larger cut-over. Incremental is almost always right: add CSS variables alongside existing hardcoded values, migrate component by component. A cut-over is only justified when the codebase is small enough to finish in one sprint. A half-done cut-over is worse than no migration at all.
A Real Rollout
What it actually looks like to ship this in a company, with a team, constraints, and a product that can't stop.
Context
We were a small team of engineers and designers building a white-label SaaS platform for enterprise clients. The product was two years old and growing, fast enough that no one had stopped to think about visual consistency. Components had hex values hardcoded everywhere. No token layer. No shared conventions. Three slightly different versions of what was supposed to be the same primary button, spread across three product areas.
The problem
Each new enterprise client came with a brand guide. Applying their brand required a sprint: find every hardcoded color in the codebase, update it, ship it, miss a few, patch them. We were doing this for every client. With 40+ clients in the pipeline, onboarding capacity was becoming a real bottleneck. The business case wasn't βour buttons are inconsistentβ; it was βwe're leaving revenue on the table because what should be a configuration change costs us a sprint.β
The call
I proposed a token layer and only a token layer. No new component library, no migration sprint, no stopping the product to refactor. New code uses tokens; old code stays as-is. Layer 2 could wait until we felt the pain of inconsistent APIs, which happened about three months in. We skipped building Layer 3 and reached for Radix only when we hit real accessibility gaps. The call I'd make differently: I waited too long to align on token naming with design. We had two weeks where engineering used --color-action and Figma used primary. A 30-minute naming session in week one would have prevented two weeks of handoff friction.
How I ran it
Getting design alignment was easier than expected once I stopped talking about architecture and started showing the mapping: Color/Primary/Default in Figma β --color-primary in CSS. Designers took ownership of the naming; I committed to matching it exactly in code. Getting engineers to change was slower; the team had shipped successfully with hardcoded values for two years. What worked: a linting rule that flagged hardcoded hex values in new files only, not old ones. No migration sprint. No stopping feature work. Adoption happened because the new pattern was visibly easier: changing a client's brand was editing one JSON file instead of grepping the codebase.
The outcome
Onboarding a new white-label client went from a sprint to 20 minutes. A 10-line JSON file per client set --color-primary, --color-secondary, and --radius-base at runtime with zero component changes. We never finished migrating the old hardcoded values. We didn't need to: the problem was solved at the boundary, not the interior. Old code stayed; new code used tokens; clients got onboarded without touching the codebase.
Applied to This Portfolio
This portfolio doesn't just teach the three-layer architecture. It uses it. The components below are the actual components/ui/ library built for this site using the CVA pattern described above.
Button
4 variants Γ 3 sizes. Supports asChild for rendering as <a> or <Link> without prop explosion.
Badge
Semantic variants for labeling content, used for complexity indicators, status tags, and metadata.
Callout
Replaces four separate box patterns (box-info, box-success, box-warning, box-yellow) that were hardcoded throughout the codebase.
Info
Success
Warning
Note
The Refactor
Shared layout components (DocsShell, CodeBlock, CodeWithPreview, ExampleViewer) and content pages were updated to replace inline styles with semantic Tailwind utilities bridged from the CSS token layer.
The token bridge
tailwind.config.js maps CSS custom properties to Tailwind color utilities, text-content resolves to hsl(var(--content-text)). This means the CSS variables in globals.css stay unchanged, dark mode keeps working, and every component drops its inline styles.Hot Takes
shadcn/ui won.
The "build vs buy" debate for most product teams is over. shadcn gave engineers ownership (you copy files, you own them), accessibility (Radix under the hood), and a modern token system out of the box. The teams still rolling their own from scratch are spending a sprint on what shadcn gives you on day one.
MUI is a red flag in a consumer product.
MUI is excellent for internal tooling where speed matters more than brand. On a consumer product, fighting MUI's opinion system costs more than the time it saves. Every override adds specificity debt. I've seen teams spend more time working around MUI than they spent on features.
"We'll add dark mode later" is a lie.
I've heard it on every project that didn't ship with dark mode. The later never comes. CSS tokens make dark mode a near-zero-cost addition from day one: one `prefers-color-scheme` block in globals.css. Retrofitting dark mode into hardcoded styles is a 2-week project that touches 200 files.
Your design system is not a product.
Teams that treat their internal component library as a product, with versioning, changelogs, and a dedicated team, are over-engineering. Unless you're shipping the library externally or supporting 10+ separate app codebases, the overhead kills engineering velocity faster than it helps.