renderpx

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.

The result after 2 years of independent work

Three teams, three "primary blue" buttons. Built independently. All slightly off.

Marketing

#3b82f6

Checkout

#2563eb

Settings

#1d4ed8

Rebrand to a new primary color? You're doing a search-and-replace across the entire codebase. Miss one and it shows in production.

Three teams, three blues — same product, same brandtsx
Loading...

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.

Live token editor — change --primary, see it cascade

Change --primary — all components update from a single variable:

Primary button

Status badge

Active

Focused input

Progress bar

68% complete

One token, total cascade — change the color below and watchcss
Loading...

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.

Button explorer — try different variants and sizes

variant

size

<Button variant="primary" size="md" />
CVA variant pattern — TypeScript-safe, self-documentingtsx
Loading...

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

Accessible accordion — no library. Correct aria-expanded, aria-controls, and role="region".

Radix Dialog — accessibility handled, you own the stylestsx
Loading...

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:

ApproachWhen to useTrade-offs
Roll your own (CSS + variants)Small team, custom brand, full controlYou build everything — tokens, variants, accessibility
shadcn/uiMost production appsCopy-paste components, you own the code
Radix UI (unstyled)Custom brand + complex interactionsYou write all styles; primitives handle behavior
Headless UI (Tailwind Labs)Tailwind-heavy codebasesSmaller API surface than Radix, tightly coupled to Tailwind
MUI / Chakra / MantineInternal tools, admin panels, rapid prototypingOpinionated 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

Naive

Inline 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.

tsx
Loading...

Why this works

Works when: • Prototyping or proof-of-concept • Solo project with no expectation of maintenance • One-off components that won't be reused Fast to write. No abstractions to learn. Just ship it.

When this breaks

When the design team updates the primary color, you search-and-replace across the codebase and inevitably miss one. You end up with a product where 3 different blues coexist in production. When you need a "disabled" state or "loading" state, every team implements it differently. When a new engineer joins and asks "what's our primary button?", there's no answer — there are seven slightly different versions scattered around.

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.

// tenant-config.json { "primary": "24 83% 53%", "secondary": "142 76% 36%", "radius": "0.75rem" } // applied at runtime: document.documentElement.style.setProperty('--color-primary', config.primary)

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 — messy. Radix's asChild pattern is cleaner: your Button merges its styles onto whatever child element you provide.

tsx
Loading...

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.

tsx
Loading...

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 — 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.

components/ui/button.tsxtsx
Loading...

Badge

Semantic variants for labeling content — used for complexity indicators, status tags, and metadata.

DefaultSuccessWarningMuted
smmdlg
tsx
Loading...

Callout

Replaces four separate box patterns (box-info, box-success, box-warning, box-yellow) that were hardcoded throughout the codebase.

Info

Use this for neutral context, tips, and supplementary explanations.

Success

Use this for correct patterns, recommended approaches, and wins.

Warning

Use this for gotchas, anti-patterns, and things to avoid.

Note

Use this for important nuances that don't fit the other categories.
tsx
Loading...

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.

Before
tsx
Loading...
After
tsx
Loading...

The token bridge

The key step: 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.