A design system is the single source of truth for your product's visual language — colors, typography, spacing, and components all living in one place. Without one, every new page is a negotiation between inconsistent styles. With one, every developer on your team ships pixel-perfect UI on the first try. This guide walks you through building a production-ready design system in Next.js from zero, step by step.
A design system is not a component library. A component library is the implementation. A design system is the decision — the rules that govern what your UI is allowed to look like, and why. It consists of three layers that build on each other:
Without a design system, a team of three developers will ship three different shades of blue, two different button heights, and four different font sizes for body text — all on the same product. A design system makes inconsistency structurally impossible.
A design system doesn't slow you down. It removes the decisions you should never be making twice — so you can spend your thinking on the decisions that actually matter.
Start with a clean Next.js 14+ project using the App Router and TypeScript. Your design system will live in a dedicated folder structure that separates tokens from components from documentation:
/src
/design-system
/tokens
colors.ts
typography.ts
spacing.ts
shadows.ts
index.ts ← exports everything
/components
/Button
Button.tsx
Button.types.ts
index.ts
/Input
/Card
/Badge
/utils
cn.ts ← className merge utility
/app
globals.css ← CSS custom properties injected here
layout.tsx
Keep your design system completely separate from your app pages. It should be importable from any page without coupling to routing, data fetching, or business logic.
Tokens are the foundation. Every color, size, and spacing value in your entire product must trace back to a token. Define them as TypeScript constants first — then expose them as CSS custom properties for runtime use.
// src/design-system/tokens/colors.ts
export const colors = {
// Brand
primary: {
50: '#EEF2FF',
100: '#E0E7FF',
400: '#818CF8',
500: '#6366F1', // ← your main brand color
600: '#4F46E5',
900: '#312E81',
},
// Neutrals
gray: {
0: '#FFFFFF',
50: '#F9FAFB',
100: '#F3F4F6',
200: '#E5E7EB',
400: '#9CA3AF',
600: '#4B5563',
800: '#1F2937',
900: '#111827',
950: '#0A0A0F',
},
// Semantic
success: '#10B981',
warning: '#F59E0B',
error: '#EF4444',
info: '#3B82F6',
} as const;
// src/design-system/tokens/typography.ts
export const typography = {
fontFamily: {
sans: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
mono: '"JetBrains Mono", "Fira Code", monospace',
},
fontSize: {
xs: '0.75rem', // 12px
sm: '0.875rem', // 14px
base: '1rem', // 16px
lg: '1.125rem', // 18px
xl: '1.25rem', // 20px
'2xl':'1.5rem', // 24px
'3xl':'1.875rem', // 30px
'4xl':'2.25rem', // 36px
'5xl':'3rem', // 48px
},
fontWeight: {
normal: '400',
medium: '500',
semibold: '600',
bold: '700',
extrabold: '800',
black: '900',
},
lineHeight: {
tight: '1.2',
snug: '1.4',
normal: '1.6',
relaxed:'1.75',
loose: '2',
},
} as const;
// src/design-system/tokens/spacing.ts
// Strict 4px base unit — every value is a multiple of 4
export const spacing = {
0: '0px',
1: '4px',
2: '8px',
3: '12px',
4: '16px',
5: '20px',
6: '24px',
8: '32px',
10: '40px',
12: '48px',
16: '64px',
20: '80px',
24: '96px',
32: '128px',
} as const;
TypeScript tokens are great for type safety, but CSS custom properties let you use tokens anywhere — including in plain CSS, Tailwind utilities, and third-party components. Inject them in your globals.css:
/* src/app/globals.css */
:root {
/* Colors */
--color-primary: #6366F1;
--color-primary-light: #818CF8;
--color-primary-dark: #4F46E5;
--color-gray-0: #FFFFFF;
--color-gray-50: #F9FAFB;
--color-gray-100: #F3F4F6;
--color-gray-800: #1F2937;
--color-gray-900: #111827;
--color-gray-950: #0A0A0F;
/* Semantic */
--color-bg: var(--color-gray-0);
--color-surface: var(--color-gray-50);
--color-border: var(--color-gray-200);
--color-text: var(--color-gray-900);
--color-muted: var(--color-gray-400);
/* Typography */
--font-sans: 'Inter', -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
/* Spacing */
--space-1: 4px; --space-2: 8px; --space-3: 12px;
--space-4: 16px; --space-6: 24px; --space-8: 32px;
--space-12: 48px; --space-16: 64px;
/* Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
}
/* Dark mode — flip semantic tokens only */
[data-theme="dark"] {
--color-bg: var(--color-gray-950);
--color-surface: var(--color-gray-900);
--color-border: rgba(255,255,255,0.08);
--color-text: var(--color-gray-50);
--color-muted: #606880;
}
Notice the pattern: primitive tokens (--color-gray-900) never change. Semantic tokens (--color-text) point to primitives and swap in dark mode. This is how you build a theme system that scales without duplication.
With tokens locked in, build your first base component — the Button. It is the most-used component in any product and forces you to solve the hardest design system problems: variants, sizes, states, and accessibility.
// src/design-system/components/Button/Button.types.ts
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive'
size?: 'sm' | 'md' | 'lg'
loading?: boolean
icon?: React.ReactNode
}
// src/design-system/components/Button/Button.tsx
import { forwardRef } from 'react'
import { cn } from '@/design-system/utils/cn'
import type { ButtonProps } from './Button.types'
const base = [
'inline-flex items-center justify-center gap-2',
'font-semibold transition-all duration-150',
'focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-[--color-primary] focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
].join(' ')
const variants = {
primary: 'bg-[--color-primary] text-white hover:bg-[--color-primary-dark]',
secondary: 'bg-[--color-surface] text-[--color-text] border border-[--color-border] hover:bg-[--color-gray-100]',
ghost: 'text-[--color-text] hover:bg-[--color-surface]',
destructive: 'bg-[--color-error] text-white hover:opacity-90',
}
const sizes = {
sm: 'h-8 px-3 text-sm rounded-[--radius-sm]',
md: 'h-10 px-4 text-sm rounded-[--radius-md]',
lg: 'h-12 px-6 text-base rounded-[--radius-md]',
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', loading, icon, children, className, ...props }, ref) => (
<button
ref={ref}
className={cn(base, variants[variant], sizes[size], className)}
disabled={loading || props.disabled}
{...props}
>
{loading ? <span className="animate-spin">⟳</span> : icon}
{children}
</button>
)
)
Button.displayName = 'Button'
Every value traces back to a CSS custom property from your tokens. No hardcoded hex codes inside components. If your brand color changes, you update one line in globals.css and every component updates instantly.
The cn() utility merges class names and resolves Tailwind conflicts. It is the most important utility in any Tailwind-based design system:
// src/design-system/utils/cn.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Usage: cn('px-4 py-2', condition && 'bg-red-500', className)
// Resolves conflicts: cn('px-4', 'px-6') → 'px-6' (last wins)
npm install clsx tailwind-merge
Make your design tokens available as Tailwind utilities so developers can use them with the same familiar class API:
// tailwind.config.ts
import type { Config } from 'tailwindcss'
const config: Config = {
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#6366F1',
light: '#818CF8',
dark: '#4F46E5',
},
gray: {
950: '#0A0A0F',
},
},
fontFamily: {
sans: ['Inter', '-apple-system', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
spacing: {
'18': '72px',
'22': '88px',
},
borderRadius: {
DEFAULT: '8px',
},
},
},
}
export default config
Dark mode in a token-based system is trivial — because you already separated primitives from semantics in Step 3. Add a theme toggle that sets data-theme="dark" on the root element:
// src/design-system/components/ThemeToggle/ThemeToggle.tsx
'use client'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const [dark, setDark] = useState(false)
useEffect(() => {
document.documentElement.setAttribute(
'data-theme',
dark ? 'dark' : 'light'
)
}, [dark])
return (
<button
onClick={() => setDark(d => !d)}
aria-label="Toggle dark mode"
className="p-2 rounded-[--radius-md] hover:bg-[--color-surface]"
>
{dark ? '☀️' : '🌙'}
</button>
)
}
Because your components use var(--color-bg) and var(--color-text) instead of hardcoded colors, flipping the data-theme attribute instantly themes your entire application. Zero component changes required.
A design system nobody can find is no design system at all. Create a barrel export so every component is importable from one path, and document your token decisions inline:
// src/design-system/index.ts
// ── Components ──────────────────────────────
export { Button } from './components/Button'
export { Input } from './components/Input'
export { Card } from './components/Card'
export { Badge } from './components/Badge'
export { ThemeToggle } from './components/ThemeToggle'
// ── Tokens ──────────────────────────────────
export { colors } from './tokens/colors'
export { typography } from './tokens/typography'
export { spacing } from './tokens/spacing'
// ── Utils ───────────────────────────────────
export { cn } from './utils/cn'
// ── Types ───────────────────────────────────
export type { ButtonProps } from './components/Button/Button.types'
// Usage anywhere in your app:
import { Button, Card, cn, colors } from '@/design-system'
| Layer | What to Build | Priority |
|---|---|---|
| Tokens | Colors, Typography, Spacing, Shadows, Radius | Do first |
| CSS Variables | Primitives + Semantic tokens in globals.css | Do first |
| Utilities | cn(), formatters, type helpers | Do first |
| Base Components | Button, Input, Badge, Card, Avatar | Week 1 |
| Form Components | Select, Checkbox, Radio, Textarea, Switch | Week 1 |
| Feedback | Toast, Alert, Modal, Tooltip | Week 2 |
| Navigation | Navbar, Sidebar, Breadcrumb, Tabs | Week 2 |
| Data Display | Table, List, Stats Card, Chart wrappers | Week 3 |
| Dark Mode | Semantic token swap + ThemeToggle | Week 3 |
| Documentation | Storybook or custom /design page | Ongoing |
#6366F1 inside a component instead of var(--color-primary), your entire theme system breaks the moment a brand color changesThe best design system is the one your team actually uses. Start small, document as you go, and let real product needs drive what you build next — not theoretical completeness.
You don't have to build everything from scratch. These tools pair well with the architecture described in this guide:
A design system is not a one-time project — it is a living product that evolves with your application. The architecture described in this guide gives you the foundation: tokens that drive CSS variables that drive components that drive your entire UI. Every decision flows from the top, and every change propagates downward automatically.
Start with your token file, add your CSS custom properties, build your first three components, and ship. The rest of the system will reveal itself through the real needs of your product. That is the only way a design system stays relevant — by being used, not by being complete.
For ready-made components, color tools, and animation resources to complement your system, visit uidrop.dev — everything is free and copy-paste ready.
Recent Posts
Micro-Interactions: The Small Animations That Make Your UI Feel Premium
14 Jul 2026
Why Your Next.js App Is Slow — And How to Fix It in a Weekend
14 Jul 2026
Will AI Agents Like Claude Replace Developers? The Honest Answer Nobody Is Giving You
12 Jul 2026
NextGen by uidrop.dev: AI Landing Page Templates So Cinematic They Should Charge Admission
12 Jul 2026
Special Offer
Pro Components & Premium Themes
Production-ready UI kits for faster shipping.