A design system makes your UI consistent, accessible, and fast to iterate. We’ll define design tokens (colors, spacing, radii, typography) as CSS variables, wire light/dark themes, and build typed UI primitives (Button, Input, Card). Everything is copy‑paste ready and verified for errors.
🧩 Concepts in 30 seconds
- Tokens: the single source of truth for colors/spacing/typography.
- Primitives: small, reusable components that read tokens.
- Variants: controlled style options (e.g.,
primary | secondary | danger). - Theming: switch token values for light/dark without changing component code.
1) Tokens as CSS variables (light & dark)
We use HSL/hex via var(--token) so classes can read them with Tailwind’s arbitrary values.
/* src/styles/tokens.css */
:root {
/* Base colors */
--color-bg: #ffffff;
--color-fg: #111111;
--color-muted: #6b7280; /* slate-500 */
--color-primary: #2563eb; /* blue-600 */
--color-danger: #dc2626; /* red-600 */
/* Surfaces */
--surface: #ffffff;
--surface-fore: var(--color-fg);
--border: #e5e7eb; /* slate-200 */
/* Radii / Shadows / Spacing / Type */
--radius: 12px;
--shadow-sm: 0 1px 2px rgba(0,0,0,.06);
--shadow-md: 0 4px 10px rgba(0,0,0,.08);
--leading: 1.5;
--font-sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Noto Sans,
Ubuntu, Cantarell, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji";
}
/* Dark theme toggled by <html data-theme="dark"> */
:root[data-theme="dark"] {
--color-bg: #0f172a; /* slate-900 */
--color-fg: #f8fafc; /* slate-50 */
--color-muted: #94a3b8; /* slate-400 */
--color-primary: #3b82f6; /* blue-500 */
--color-danger: #ef4444; /* red-500 */
--surface: #111827; /* gray-900 */
--surface-fore: var(--color-fg);
--border: #1f2937; /* gray-800 */
}
Apply globally and set sane defaults:
/* src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@import './styles/tokens.css';
@import './styles/typography.css';
@import './styles/motion.css';
html, body, #root { height: 100%; }
body {
background: var(--color-bg);
color: var(--color-fg);
line-height: var(--leading);
font-family: var(--font-sans);
}
/* Surface utility for quick prototypes */
.card {
background: var(--surface);
color: var(--surface-fore);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
Optional extras (typography & reduced‑motion):
/* src/styles/typography.css */
.h1 { font-size: clamp(1.75rem, 2vw + 1rem, 2.25rem); font-weight: 700; }
.h2 { font-size: clamp(1.5rem, 1.5vw + 1rem, 1.875rem); font-weight: 600; }
.subtle { color: var(--color-muted); }
/* src/styles/motion.css */
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
2) Tailwind config (reads our tokens)
We’ll extend a couple of theme values and otherwise use arbitrary values with CSS variables.
// tailwind.config.ts
import type { Config } from 'tailwindcss';
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
borderRadius: { xl: 'var(--radius)' },
boxShadow: { skin: 'var(--shadow-md)' },
},
},
plugins: [],
} satisfies Config;
Example usage:
<div className="bg-[var(--surface)] text-[var(--surface-fore)] border border-[var(--border)] rounded-xl p-4 shadow-skin">
Token‑powered surface
</div>
3) Class name helper (cn) — no errors
// src/lib/cn.ts
import clsx from 'clsx';
import { twMerge } from 'tailwind-merge';
// Merge conditional classes and resolve Tailwind conflicts safely
export function cn(...inputs: unknown[]) {
return twMerge(clsx(inputs));
}
4) Button primitive (typed variants + a11y)
Uses class-variance-authority (CVA) for typed variants. Install once:
pnpm add class-variance-authority
// src/components/ui/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import type { ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
type Variant = 'primary' | 'secondary' | 'danger';
// Base + variant classes
const buttonStyles = cva(
'inline-flex items-center justify-center rounded-xl font-medium select-none transition-colors '
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 '
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
{
variants: {
variant: {
primary: 'bg-[var(--color-primary)] text-white hover:brightness-95 ring-[var(--color-primary)] ring-offset-[var(--color-bg)]',
secondary: 'bg-[var(--surface)] text-[var(--surface-fore)] border border-[var(--border)] hover:bg-[var(--color-bg)] ring-[var(--border)] ring-offset-[var(--color-bg)]',
danger: 'bg-[var(--color-danger)] text-white hover:brightness-95 ring-[var(--color-danger)] ring-offset-[var(--color-bg)]',
},
size: {
sm: 'px-2 py-1 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
},
);
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonStyles> {
isLoading?: boolean;
}
export function Button({ className, variant, size, isLoading, children, ...props }: ButtonProps) {
return (
<button
className={cn(buttonStyles({ variant, size }), className)}
aria-busy={isLoading ? 'true' : undefined}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading ? '⏳' : children}
</button>
);
}
5) Input primitive (labels + error wiring)
// src/components/ui/Input.tsx
import type { InputHTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export function Input({ label, error, className, id, name, ...props }: InputProps) {
const inputId = id ?? name ?? `in-${Math.random().toString(36).slice(2)}`; // stable enough for demo
const errId = `${inputId}-err`;
return (
<label className="block text-sm font-medium" htmlFor={inputId}>
{label && <span className="mb-1 block">{label}</span>}
<input
id={inputId}
name={name}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errId : undefined}
className={cn(
'w-full rounded-xl px-3 py-2 shadow-sm focus:ring-2 border',
error ? 'border-red-500 focus:ring-red-500' : 'border-[var(--border)] focus:ring-[var(--color-primary)]',
'bg-[var(--surface)] text-[var(--surface-fore)]',
className,
)}
{...props}
/>
{error && <span id={errId} className="text-sm text-red-600">{error}</span>}
</label>
);
}
6) Card primitive (token surface)
// src/components/ui/Card.tsx
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
export function Card({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cn('bg-[var(--surface)] text-[var(--surface-fore)] border border-[var(--border)] rounded-xl p-4 shadow-skin', className)}>
{children}
</div>
);
}
7) Theme toggle (tokens respond automatically)
Assuming you have a ThemeProvider that sets <html data-theme>.
// src/components/ThemeToolbar.tsx
import { Button } from '@/components/ui/Button';
import { useTheme } from '@/contexts/ThemeContext';
export function ThemeToolbar() {
const { theme, toggle } = useTheme();
return (
<div className="flex items-center gap-2">
<span className="subtle">Theme:</span>
<Button variant="secondary" onClick={toggle}>Toggle ({theme})</Button>
</div>
);
}
8) Quick visual check page (optional)
// src/pages/design/SystemDemo.tsx
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
export default function SystemDemo() {
return (
<div className="space-y-4">
<h1 className="h1">Design System Demo</h1>
<p className="subtle">Token‑driven theming with accessible primitives.</p>
<div className="flex gap-2">
<Button>Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="danger">Danger</Button>
</div>
<Card>
<div className="h2 mb-2">Card Title</div>
<p>Card content using token surfaces and borders.</p>
<div className="mt-3 grid gap-2">
<Input label="Email" type="email" placeholder="you@example.com" />
<Input label="Title" error="Title is required" />
</div>
</Card>
</div>
);
}
Add a route like /design and flip light/dark to verify.
✅ Wrap‑Up
You now have a token‑driven design system:
- CSS variables for colors/surfaces/spacing/typography
- Tailwind utilities that read tokens via
var(--token) - Accessible, typed primitives (Button, Input, Card)
- Light/Dark theming through
<html data-theme>
Comments
Post a Comment