UI/UX & Design Engineering
Building a Design System with Tailwind CSS and Next.js
Last updated: April 14, 2026
TL;DR
A design system is not a component library. It's a set of constraints that make every interface you build feel like it belongs to the same product. I've built three production design systems — iamuvin.com (dark luxury aesthetic), EuroParts Lanka (automotive e-commerce), and FreshMart (Harvest Modern palette) — and the lesson every time is the same: start with tokens, not components. This guide walks through how I structure design systems using Tailwind CSS v4's @theme directive and CSS custom properties as the single source of truth, build a scalable color system with OKLCH, define typography and spacing scales, architect components using atomic design principles, and implement dark mode that actually works. Every code example is pulled from real production code.
What a Design System Actually Needs
Most tutorials start with a button component. That's backwards. A design system is a hierarchy, and components sit near the top — not the bottom.
Here's how I think about the layers, from foundation to surface:
- Design tokens — Colors, spacing, typography, shadows, motion, z-index. The raw values that define your visual language.
- Token application — How tokens map to semantic uses.
--color-primaryis a token.--color-button-bgis a semantic application. - Utility patterns — Tailwind classes that compose tokens into reusable visual patterns.
- Base components — Atoms like
Button,Input,Badge. Simple, composable, unstyled by default. - Composite components — Molecules and organisms like
SearchBar,PricingCard,NavigationMenu. - Page patterns — Layouts and templates that govern how components arrange on screen.
When I started building the iamuvin.com design system, I made the mistake of jumping straight to components. I built a beautiful card component, then realized the spacing didn't match the header, the colors were hardcoded, and dark mode was an afterthought. I tore it all down and started with tokens.
The rule I follow now: no component gets built until the token layer is complete. Every value in every component must trace back to a token. If it doesn't, either the token is missing or the value shouldn't exist.
For client projects, this discipline is what separates a site that looks professional from one that looks like someone glued together shadcn/ui defaults. The tokens carry the brand.
Design Tokens as CSS Variables
Tailwind CSS v4 changed how I think about design tokens entirely. In v3, tokens lived in tailwind.config.js — a JavaScript file separate from your styles. In v4, tokens are CSS custom properties defined with @theme, which means they live in your stylesheet, are accessible to any CSS, and work with browser DevTools natively.
Here's the token foundation I use on every project:
/* tokens.css — the single source of truth */
@theme {
/* Spacing scale — consistent rhythm */
--spacing-xs: 0.25rem; /* 4px */
--spacing-sm: 0.5rem; /* 8px */
--spacing-md: 1rem; /* 16px */
--spacing-lg: 1.5rem; /* 24px */
--spacing-xl: 2rem; /* 32px */
--spacing-2xl: 3rem; /* 48px */
--spacing-3xl: 4rem; /* 64px */
--spacing-4xl: 6rem; /* 96px */
/* Border radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px oklch(0 0 0 / 0.05);
--shadow-md: 0 4px 6px oklch(0 0 0 / 0.07);
--shadow-lg: 0 10px 15px oklch(0 0 0 / 0.1);
--shadow-glow: 0 0 20px oklch(0.7 0.15 70 / 0.3);
/* Z-index scale */
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 500;
/* Motion */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--duration-micro: 200ms;
--duration-standard: 400ms;
--duration-dramatic: 600ms;
}The key insight is that @theme tokens automatically generate Tailwind utility classes. Define --spacing-xl: 2rem and you can immediately use p-xl, m-xl, gap-xl in your markup. No extra configuration. No plugin. The token *is* the utility.
For values that need to change between themes or modes, I use regular CSS custom properties outside @theme:
/* Semantic tokens — change with context */
:root {
--surface-primary: oklch(0.13 0.02 260);
--surface-secondary: oklch(0.17 0.02 260);
--surface-elevated: oklch(0.20 0.02 260);
--text-primary: oklch(1 0 0);
--text-secondary: oklch(0.82 0.02 260);
--text-muted: oklch(0.55 0.03 260);
--border-default: oklch(0.25 0.02 260);
--border-subtle: oklch(0.20 0.02 260);
}This separation matters. @theme tokens are static — they define the design language. Semantic tokens are dynamic — they change with dark mode, brand themes, or user preferences. Mixing them creates a system that's impossible to maintain.
Color System with OKLCH
Every design system I've built in the last year uses OKLCH for color definition. If you're still using hex or HSL, you're fighting the color space instead of working with it.
OKLCH has three channels: L (lightness, 0-1), C (chroma, 0-0.4), and H (hue, 0-360). The critical advantage over HSL is perceptual uniformity — a lightness of 0.5 in OKLCH looks equally bright regardless of hue. In HSL, hsl(60, 100%, 50%) (yellow) looks far brighter than hsl(240, 100%, 50%) (blue) despite identical lightness values.
Here's how I build a full color scale from a single brand color. This is the actual system from iamuvin.com:
@theme {
/* IAMUVIN Orange — brand primary */
--color-primary-50: oklch(0.97 0.03 70);
--color-primary-100: oklch(0.93 0.06 70);
--color-primary-200: oklch(0.87 0.10 70);
--color-primary-300: oklch(0.80 0.13 70);
--color-primary-400: oklch(0.74 0.15 70);
--color-primary-500: oklch(0.70 0.16 70); /* #F7931A equivalent */
--color-primary-600: oklch(0.62 0.15 70);
--color-primary-700: oklch(0.53 0.13 70);
--color-primary-800: oklch(0.43 0.10 70);
--color-primary-900: oklch(0.33 0.07 70);
--color-primary-950: oklch(0.22 0.04 70);
}The pattern is consistent: keep the hue fixed at 70 (warm orange), vary lightness from 0.97 (near white) to 0.22 (near black), and curve chroma to peak in the mid-range where the color is most vibrant.
For EuroParts Lanka, the brand blue follows the same structure but with hue 250. For FreshMart's Harvest Modern palette, the primary green sits at hue 145. The formula is the same — only the hue and chroma peak change.
I also define status colors with OKLCH:
@theme {
--color-success: oklch(0.72 0.19 160);
--color-danger: oklch(0.63 0.22 25);
--color-warning: oklch(0.80 0.16 85);
--color-info: oklch(0.68 0.15 250);
}Because OKLCH is perceptually uniform, these status colors have equal visual weight. None shouts louder than another. In HSL, getting this balance right requires manual tuning for every color. In OKLCH, the math handles it.
Typography Scale
Typography is the skeleton of a design system. Get it wrong and no amount of color or spacing will save you.
I use a modular scale based on a ratio. For iamuvin.com, the ratio is 1.25 (major third). For FreshMart, it's 1.2 (minor third) because grocery content is denser and needs tighter hierarchy.
@theme {
/* Font families */
--font-display: "Plus Jakarta Sans", system-ui, sans-serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
/* Type scale — 1.25 ratio (major third) */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.563rem; /* 25px */
--text-3xl: 1.953rem; /* 31px */
--text-4xl: 2.441rem; /* 39px */
--text-5xl: 3.052rem; /* 49px */
--text-6xl: 3.815rem; /* 61px */
/* Line heights paired to sizes */
--leading-tight: 1.15;
--leading-snug: 1.3;
--leading-normal: 1.5;
--leading-relaxed: 1.65;
/* Font weights */
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
}The rule for pairing line heights: display text (headings) gets --leading-tight or --leading-snug. Body text gets --leading-normal or --leading-relaxed. Code gets --leading-normal. Never use the same line height for headings and body — it makes headings look loose and body text look cramped.
In Next.js, I load fonts with next/font and assign them to CSS variables:
import { Plus_Jakarta_Sans, Inter, JetBrains_Mono } from "next/font/google";
const jakarta = Plus_Jakarta_Sans({
subsets: ["latin"],
variable: "--font-jakarta",
display: "swap",
});
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
const jetbrains = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains",
display: "swap",
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html
lang="en"
className={`${jakarta.variable} ${inter.variable} ${jetbrains.variable}`}
>
<body className="font-body text-base leading-normal text-[--text-primary] bg-[--surface-primary]">
{children}
</body>
</html>
);
}The display: "swap" is critical — it prevents invisible text during font loading. The variable approach means the fonts are available everywhere as CSS custom properties without importing them in every component.
Spacing and Sizing
Spacing is where most design systems fall apart. Developers reach for arbitrary values — mt-[13px], p-[22px] — and the visual rhythm breaks down.
I enforce a spacing scale based on a 4px grid. Every spacing value in the system is a multiple of 4:
@theme {
--spacing-0: 0;
--spacing-1: 0.25rem; /* 4px */
--spacing-2: 0.5rem; /* 8px */
--spacing-3: 0.75rem; /* 12px */
--spacing-4: 1rem; /* 16px */
--spacing-5: 1.25rem; /* 20px */
--spacing-6: 1.5rem; /* 24px */
--spacing-8: 2rem; /* 32px */
--spacing-10: 2.5rem; /* 40px */
--spacing-12: 3rem; /* 48px */
--spacing-16: 4rem; /* 64px */
--spacing-20: 5rem; /* 80px */
--spacing-24: 6rem; /* 96px */
--spacing-32: 8rem; /* 128px */
}Notice the scale isn't linear. It compresses at the small end (4, 8, 12, 16, 20, 24) where fine control matters, then expands (32, 48, 64, 80, 96, 128) where large gaps define sections.
For component-level sizing, I define a separate set of tokens:
@theme {
--size-icon-sm: 1rem; /* 16px */
--size-icon-md: 1.25rem; /* 20px */
--size-icon-lg: 1.5rem; /* 24px */
--size-avatar-sm: 2rem; /* 32px */
--size-avatar-md: 2.5rem; /* 40px */
--size-avatar-lg: 3rem; /* 48px */
--size-button-height-sm: 2rem;
--size-button-height-md: 2.5rem;
--size-button-height-lg: 3rem;
--size-input-height: 2.5rem;
--size-sidebar-width: 16rem;
--size-container-max: 80rem;
}The biggest spacing mistake I see in codebases is inconsistent section padding. On FreshMart, every page section uses py-16 md:py-24 — no exceptions. On iamuvin.com, it's py-20 md:py-32 because the dark luxury aesthetic needs more breathing room. The exact values matter less than the consistency.
Component Architecture — Atomic Design
I structure every design system using atomic design, adapted for React and Next.js. The original Brad Frost model has five levels — I use three that map cleanly to a file structure:
src/
components/
ui/ # Atoms — smallest units
button.tsx
badge.tsx
input.tsx
avatar.tsx
skeleton.tsx
patterns/ # Molecules — composed atoms
search-bar.tsx
stat-card.tsx
nav-link.tsx
form-field.tsx
blocks/ # Organisms — sections
hero.tsx
pricing-table.tsx
feature-grid.tsx
testimonial-carousel.tsx
layouts/ # Templates
page-layout.tsx
dashboard-layout.tsx
auth-layout.tsxThe rules:
- Atoms (
ui/) import nothing frompatterns/orblocks/. They depend only on tokens and utilities. - Molecules (
patterns/) compose atoms. ASearchBarcombinesInputandButton. - Organisms (
blocks/) compose molecules and atoms into page sections. - Templates (
layouts/) define the skeleton of a page without content.
This dependency direction is non-negotiable. If a block needs to import from another block, that's a sign something should be extracted into a pattern.
Building Core Components
Let me show how tokens flow into actual components. Here's the Button atom from iamuvin.com — the most reused component in any system:
import { type ButtonHTMLAttributes, forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
[
"inline-flex items-center justify-center gap-2",
"font-display font-semibold",
"rounded-md transition-all",
"duration-[--duration-micro] ease-[--ease-out-expo]",
"focus-visible:outline-2 focus-visible:outline-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
"cursor-pointer select-none",
],
{
variants: {
variant: {
primary: [
"bg-[--color-primary-500] text-white",
"hover:bg-[--color-primary-600]",
"focus-visible:outline-[--color-primary-500]",
"shadow-[--shadow-sm] hover:shadow-[--shadow-md]",
],
secondary: [
"bg-[--surface-secondary] text-[--text-primary]",
"border border-[--border-default]",
"hover:bg-[--surface-elevated]",
"focus-visible:outline-[--color-primary-500]",
],
ghost: [
"text-[--text-secondary]",
"hover:bg-[--surface-secondary] hover:text-[--text-primary]",
"focus-visible:outline-[--color-primary-500]",
],
danger: [
"bg-[--color-danger] text-white",
"hover:brightness-110",
"focus-visible:outline-[--color-danger]",
],
},
size: {
sm: "h-[--size-button-height-sm] px-3 text-sm",
md: "h-[--size-button-height-md] px-4 text-sm",
lg: "h-[--size-button-height-lg] px-6 text-base",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
)
);
Button.displayName = "Button";
export { Button, buttonVariants };Notice what's happening here. Every visual value references a token — --color-primary-500, --size-button-height-md, --duration-micro, --shadow-sm. If I change the primary color in my tokens, every button updates. If I adjust the shadow scale, every button follows. No find-and-replace. No missed instances.
The class-variance-authority (CVA) library handles variant composition. It's the cleanest way to build variant-driven components with Tailwind. The cn utility merges class names with proper conflict resolution (it uses tailwind-merge under the hood).
Here's a Card pattern that demonstrates composition:
import { cn } from "@/lib/utils";
type CardProps = {
children: React.ReactNode;
className?: string;
hover?: boolean;
};
function Card({ children, className, hover = false }: CardProps) {
return (
<div
className={cn(
"rounded-lg border border-[--border-subtle]",
"bg-[--surface-secondary] p-6",
"transition-all duration-[--duration-standard] ease-[--ease-out-expo]",
hover && [
"hover:border-[--color-primary-500]/30",
"hover:shadow-[--shadow-glow]",
"hover:-translate-y-0.5",
],
className
)}
>
{children}
</div>
);
}
function CardHeader({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn("mb-4", className)}>{children}</div>;
}
function CardTitle({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<h3 className={cn("font-display text-xl font-semibold text-[--text-primary]", className)}>
{children}
</h3>
);
}
function CardDescription({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<p className={cn("text-sm text-[--text-secondary] leading-relaxed", className)}>
{children}
</p>
);
}
export { Card, CardHeader, CardTitle, CardDescription };The compound component pattern (Card, CardHeader, CardTitle, CardDescription) gives consumers flexibility without exposing implementation details. They compose, they don't configure.
Dark Mode Implementation
Dark mode is not an afterthought you bolt on — it's an architectural decision that must be present from the first token you define.
My approach: define semantic tokens for both modes using CSS custom properties and toggle with a class on <html>.
/* Light mode (default for FreshMart) */
:root {
--surface-primary: oklch(0.99 0 0);
--surface-secondary: oklch(0.97 0 0);
--surface-elevated: oklch(1 0 0);
--text-primary: oklch(0.15 0 0);
--text-secondary: oklch(0.40 0 0);
--text-muted: oklch(0.60 0 0);
--border-default: oklch(0.87 0 0);
--border-subtle: oklch(0.92 0 0);
}
/* Dark mode (default for iamuvin.com) */
.dark {
--surface-primary: oklch(0.13 0.02 260);
--surface-secondary: oklch(0.17 0.02 260);
--surface-elevated: oklch(0.20 0.02 260);
--text-primary: oklch(1 0 0);
--text-secondary: oklch(0.82 0.02 260);
--text-muted: oklch(0.55 0.03 260);
--border-default: oklch(0.25 0.02 260);
--border-subtle: oklch(0.20 0.02 260);
}The component code doesn't change at all. bg-[--surface-primary] resolves to white in light mode and deep space blue in dark mode. Zero conditional logic. Zero dark: prefixes on every class. The tokens do the work.
For the toggle, I use a React context that persists the preference to localStorage and respects prefers-color-scheme as the default:
"use client";
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "light" | "dark" | "system";
type ThemeContextType = {
theme: Theme;
resolvedTheme: "light" | "dark";
setTheme: (theme: Theme) => void;
};
const ThemeContext = createContext<ThemeContextType | null>(null);
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("system");
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("dark");
useEffect(() => {
const stored = localStorage.getItem("theme") as Theme | null;
if (stored) setTheme(stored);
}, []);
useEffect(() => {
const resolved = theme === "system" ? getSystemTheme() : theme;
setResolvedTheme(resolved);
document.documentElement.classList.toggle("dark", resolved === "dark");
localStorage.setItem("theme", theme);
}, [theme]);
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => {
if (theme === "system") {
const resolved = getSystemTheme();
setResolvedTheme(resolved);
document.documentElement.classList.toggle("dark", resolved === "dark");
}
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}To prevent the flash of wrong theme on page load, add an inline script in your layout.tsx before the body content:
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
var t = localStorage.getItem('theme');
var d = t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (d) document.documentElement.classList.add('dark');
})();
`,
}}
/>
</head>This runs synchronously before the first paint, so there's never a flash. It's a small script and it's worth the inline cost.
Documenting Your System
A design system without documentation is just a folder of components. I've seen teams build beautiful systems that nobody uses because nobody knows what exists.
I document at three levels:
Token reference. A page that renders every token with its value, visual swatch (for colors), and the Tailwind utility it generates. This is auto-generated from the CSS:
import { tokenGroups } from "@/lib/design-tokens";
function TokenTable({ group }: { group: string }) {
const tokens = tokenGroups[group];
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[--border-default]">
<th className="py-2 text-left font-medium text-[--text-secondary]">Token</th>
<th className="py-2 text-left font-medium text-[--text-secondary]">Value</th>
<th className="py-2 text-left font-medium text-[--text-secondary]">Utility</th>
</tr>
</thead>
<tbody>
{tokens.map((token) => (
<tr key={token.name} className="border-b border-[--border-subtle]">
<td className="py-2 font-mono text-xs">{token.name}</td>
<td className="py-2">
{token.type === "color" && (
<span
className="mr-2 inline-block size-4 rounded-sm border border-[--border-default]"
style={{ backgroundColor: token.value }}
/>
)}
<span className="font-mono text-xs text-[--text-muted]">{token.value}</span>
</td>
<td className="py-2 font-mono text-xs text-[--color-primary-500]">{token.utility}</td>
</tr>
))}
</tbody>
</table>
);
}Component showcase. Each component gets a page showing every variant, every size, every state (default, hover, focus, disabled, loading). I render these as live examples, not screenshots:
<section className="space-y-8">
<h2 className="font-display text-2xl font-bold">Button</h2>
<div className="flex flex-wrap gap-4">
<Button variant="primary" size="sm">Small Primary</Button>
<Button variant="primary" size="md">Medium Primary</Button>
<Button variant="primary" size="lg">Large Primary</Button>
</div>
<div className="flex flex-wrap gap-4">
<Button variant="secondary" size="md">Secondary</Button>
<Button variant="ghost" size="md">Ghost</Button>
<Button variant="danger" size="md">Danger</Button>
</div>
<div className="flex flex-wrap gap-4">
<Button variant="primary" disabled>Disabled</Button>
</div>
</section>Usage guidelines. When to use which variant. What spacing to use between stacked buttons. How the component behaves on mobile. This is written prose, not generated — it captures the design intent that code can't express.
The documentation lives in the same repo as the components, under src/app/design-system/. It's a Next.js route, so it's always up to date with the actual code. No separate Storybook instance that falls out of sync.
My IAMUVIN Design System
Let me put it all together. The iamuvin.com design system is built on these specific choices, and understanding *why* each choice was made matters more than the values themselves.
Dark-first. iamuvin.com is a portfolio and services site. The dark aesthetic creates a sense of depth and luxury that light mode can't match for this context. FreshMart is the opposite — light-first because grocery shopping feels wrong in the dark.
Orange as primary. #F7931A — IAMUVIN Orange. In OKLCH: oklch(0.70 0.16 70). It's warm, energetic, and has enough chroma to pop against dark surfaces without being aggressive. The full scale from 50 to 950 gives me flexibility for hover states, borders, and text.
Plus Jakarta Sans for display. It has geometric precision with just enough personality. Paired with Inter for body text, which is the most readable screen font available. JetBrains Mono for code blocks because it has clear character distinction and ligatures.
Generous spacing. Dark interfaces need more whitespace than light ones. Elements on dark backgrounds feel heavier, so I use larger gaps — py-20 md:py-32 for sections instead of the py-16 md:py-24 I use on lighter sites.
Glow shadows. The --shadow-glow token (0 0 20px oklch(0.7 0.15 70 / 0.3)) creates a warm orange halo on hover states. This is the signature interaction on iamuvin.com — cards, buttons, and links all glow on hover. It's a small detail that makes the interface feel alive.
Motion with restraint. Every animation uses --ease-out-expo (that cubic-bezier(0.16, 1, 0.3, 1) curve that starts fast and decelerates smoothly). Micro-interactions at 200ms, page transitions at 400ms. Nothing bounces. Nothing overshoots. The motion feels confident, not playful.
Here's the complete @theme block that drives the entire site:
@import "tailwindcss";
@theme {
/* IAMUVIN Color System */
--color-primary-50: oklch(0.97 0.03 70);
--color-primary-100: oklch(0.93 0.06 70);
--color-primary-200: oklch(0.87 0.10 70);
--color-primary-300: oklch(0.80 0.13 70);
--color-primary-400: oklch(0.74 0.15 70);
--color-primary-500: oklch(0.70 0.16 70);
--color-primary-600: oklch(0.62 0.15 70);
--color-primary-700: oklch(0.53 0.13 70);
--color-primary-800: oklch(0.43 0.10 70);
--color-primary-900: oklch(0.33 0.07 70);
--color-primary-950: oklch(0.22 0.04 70);
--color-success: oklch(0.72 0.19 160);
--color-danger: oklch(0.63 0.22 25);
--color-warning: oklch(0.80 0.16 85);
--color-info: oklch(0.68 0.15 250);
/* Typography */
--font-display: "Plus Jakarta Sans", system-ui, sans-serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
/* Motion */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--duration-micro: 200ms;
--duration-standard: 400ms;
--duration-dramatic: 600ms;
/* Radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
/* Shadows */
--shadow-sm: 0 1px 2px oklch(0 0 0 / 0.05);
--shadow-md: 0 4px 6px oklch(0 0 0 / 0.07);
--shadow-lg: 0 10px 15px oklch(0 0 0 / 0.1);
--shadow-glow: 0 0 20px oklch(0.7 0.15 70 / 0.3);
}
/* Dark mode — default for iamuvin.com */
:root {
--surface-primary: oklch(0.13 0.02 260);
--surface-secondary: oklch(0.17 0.02 260);
--surface-elevated: oklch(0.20 0.02 260);
--text-primary: oklch(1 0 0);
--text-secondary: oklch(0.82 0.02 260);
--text-muted: oklch(0.55 0.03 260);
--border-default: oklch(0.25 0.02 260);
--border-subtle: oklch(0.20 0.02 260);
}This is 50 lines of CSS that govern the entire visual identity of iamuvin.com. Every component, every page, every interaction traces back to these tokens. When I want to adjust the brand feel — maybe the orange needs more warmth, maybe the surfaces need more contrast — I change one value and the entire site follows.
That's what a design system is. Not a collection of components. A set of constraints that make consistency effortless and inconsistency impossible.
Key Takeaways
- Start with tokens, not components. Define your color, typography, spacing, shadow, and motion values before writing a single component. Every visual decision should trace back to a token.
- Use OKLCH for color. It gives you perceptual uniformity that HSL and hex can't. Building color scales becomes formulaic instead of manual.
- Tailwind v4 `@theme` is your token layer. CSS custom properties defined in
@themeautomatically generate utility classes. Your tokens and your utilities are the same thing.
- Separate static tokens from semantic tokens.
@themedefines the design language (static). CSS custom properties on:rootand.darkdefine how tokens map to UI roles (dynamic).
- Atomic design maps cleanly to file structure.
ui/for atoms,patterns/for molecules,blocks/for organisms,layouts/for templates. Enforce one-directional dependencies.
- Dark mode is a token concern, not a component concern. Toggle semantic tokens with a CSS class. Components never need to know which mode they're in.
- Document in the same repo. A design system page in your Next.js app stays in sync automatically. External documentation tools always drift.
- Consistency beats aesthetics. A system with mediocre tokens used consistently will always look more professional than beautiful tokens applied inconsistently.
*I'm Uvin Vindula↗ — a Web3 and AI engineer building from Sri Lanka and the UK. I design and build production-grade interfaces, design systems, and full-stack applications. If you need a design system built for your product, or want to level up an existing component library, let's talk about your project.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.