A design system ensures consistency across your product. Here is how to build one with Storybook.
Setup Storybook
pnpm dlx storybook@latest init
Design Tokens
// tokens/colors.ts
export const colors = {
primary: {
50: "#eff6ff",
100: "#dbeafe",
200: "#bfdbfe",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
900: "#1e3a5f",
},
neutral: {
50: "#fafafa",
100: "#f5f5f5",
200: "#e5e5e5",
400: "#a3a3a3",
600: "#525252",
800: "#262626",
900: "#171717",
},
success: { 500: "#22c55e", 600: "#16a34a" },
warning: { 500: "#eab308", 600: "#ca8a04" },
error: { 500: "#ef4444", 600: "#dc2626" },
} as const;
// tokens/spacing.ts
export const spacing = {
xs: "0.25rem",
sm: "0.5rem",
md: "1rem",
lg: "1.5rem",
xl: "2rem",
"2xl": "3rem",
"3xl": "4rem",
} as const;
// tokens/typography.ts
export const typography = {
fontFamily: {
sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
mono: '"JetBrains Mono", "Fira Code", monospace',
},
fontSize: {
xs: ["0.75rem", { lineHeight: "1rem" }],
sm: ["0.875rem", { lineHeight: "1.25rem" }],
base: ["1rem", { lineHeight: "1.5rem" }],
lg: ["1.125rem", { lineHeight: "1.75rem" }],
xl: ["1.25rem", { lineHeight: "1.75rem" }],
"2xl": ["1.5rem", { lineHeight: "2rem" }],
"3xl": ["1.875rem", { lineHeight: "2.25rem" }],
},
} as const;
Button Component
// components/Button/Button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: "bg-primary-600 text-white hover:bg-primary-700 focus-visible:ring-primary-500",
secondary: "bg-neutral-100 text-neutral-800 hover:bg-neutral-200 focus-visible:ring-neutral-400",
outline: "border border-neutral-200 bg-transparent hover:bg-neutral-50 focus-visible:ring-neutral-400",
ghost: "bg-transparent hover:bg-neutral-100 focus-visible:ring-neutral-400",
destructive: "bg-error-500 text-white hover:bg-error-600 focus-visible:ring-error-500",
},
size: {
sm: "h-8 px-3 text-xs",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{children}
</button>
);
}
);
Button.displayName = "Button";
Button Stories
// components/Button/Button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";
const meta = {
title: "Components/Button",
component: Button,
parameters: {
layout: "centered",
docs: {
description: {
component: "A versatile button component supporting multiple variants and sizes.",
},
},
},
tags: ["autodocs"],
argTypes: {
variant: {
control: "select",
options: ["primary", "secondary", "outline", "ghost", "destructive"],
},
size: {
control: "select",
options: ["sm", "md", "lg"],
},
loading: { control: "boolean" },
disabled: { control: "boolean" },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: { children: "Button", variant: "primary" },
};
export const Secondary: Story = {
args: { children: "Button", variant: "secondary" },
};
export const Outline: Story = {
args: { children: "Button", variant: "outline" },
};
export const Destructive: Story = {
args: { children: "Delete", variant: "destructive" },
};
export const Loading: Story = {
args: { children: "Saving...", loading: true },
};
export const Disabled: Story = {
args: { children: "Disabled", disabled: true },
};
export const AllVariants: Story = {
render: () => (
<div className="flex flex-wrap gap-3">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>
</div>
),
};
export const AllSizes: Story = {
render: () => (
<div className="flex items-center gap-3">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
),
};
Input Component and Story
// components/Input/Input.tsx
import { forwardRef, type InputHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
hint?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, hint, id, ...props }, ref) => {
const inputId = id ?? `input-${label?.toLowerCase().replace(/\s+/g, "-")}`;
return (
<div className="space-y-1">
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-neutral-800">
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={cn(
"w-full rounded-md border px-3 py-2 text-sm transition-colors",
"focus:outline-none focus:ring-2 focus:ring-offset-1",
error
? "border-error-500 focus:ring-error-500"
: "border-neutral-200 focus:ring-primary-500",
className
)}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
{...props}
/>
{error && (
<p id={`${inputId}-error`} className="text-xs text-error-500" role="alert">
{error}
</p>
)}
{hint && !error && (
<p id={`${inputId}-hint`} className="text-xs text-neutral-400">
{hint}
</p>
)}
</div>
);
}
);
Input.displayName = "Input";
Storybook Accessibility Addon
pnpm add -D @storybook/addon-a11y
// .storybook/main.ts
const config = {
addons: [
"@storybook/addon-a11y",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
],
};
export default config;
This automatically runs accessibility checks on every story and shows violations in the panel.
Need a Custom Design System?
We build production-ready design systems with consistent components and documentation. Contact us to create yours.