Building a Design System with Storybook and Tailwind CSS
How to build a maintainable component library with Tailwind CSS, design tokens, and Storybook—covering token architecture, variants, docs, and testing.
A design system isn't a folder of components—it's a contract. When it works, product teams ship features instead of re-deciding padding for the fifth time. I've built and maintained systems that back CRM, analytics, and billing surfaces, and the same foundations keep showing up. Here's the stack I reach for: Tailwind CSS for styling, design tokens for consistency, and Storybook for the source of truth.
Start with tokens, not components
The mistake teams make is building a <Button> before deciding what "primary" even means. Tokens come first. With Tailwind v4 you can define them directly in CSS:
@theme {
--color-brand: #6d5ef8;
--color-brand-fg: #ffffff;
--radius-control: 10px;
--space-control-x: 1rem;
}
Now every component references intent (bg-brand), never a raw hex. Change the token, and the whole system moves together. This is the single highest-leverage decision in the entire project.
Model variants explicitly
A button has a small, finite matrix: intent × size × state. Encode it with a variant helper like cva so the API is typed and the classes stay declarative:
import { cva, type VariantProps } from "class-variance-authority";
export const button = cva(
"inline-flex items-center justify-center rounded-[--radius-control] font-medium transition",
{
variants: {
intent: {
primary: "bg-brand text-brand-fg hover:opacity-90",
ghost: "bg-transparent text-brand hover:bg-brand/10",
},
size: { sm: "h-8 px-3 text-sm", md: "h-10 px-4" },
},
defaultVariants: { intent: "primary", size: "md" },
}
);
The component becomes a thin wrapper. Consumers get autocomplete for every valid combination and can't invent an unsupported one.
Make Storybook the source of truth
Storybook is where the system is documented, reviewed, and tested. Each story is a live spec:
export const Intents = () => (
<div className="flex gap-3">
<Button intent="primary">Save</Button>
<Button intent="ghost">Cancel</Button>
</div>
);
Wire in a few addons and Storybook stops being a gallery and starts being infrastructure:
- Controls — designers and PMs tweak props without touching code.
- A11y — catches contrast and ARIA issues before review.
- Interactions — turns a story into a functional test.
Guard it with tests
Two cheap layers keep regressions out. Visual snapshots (Chromatic or Playwright screenshots) catch the "who changed the shadow?" bugs. Interaction tests run inside the story and assert behavior:
import { userEvent, within, expect } from "@storybook/test";
export const Clickable = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
await expect(args.onClick).toHaveBeenCalled();
},
};
The payoff
Once tokens, variants, Storybook, and tests are in place, feature velocity jumps because nobody re-litigates the basics. New engineers learn the system by browsing stories. Designers trust that "the indigo button" means one thing everywhere. That predictability—not any single component—is what a design system actually sells.
Build the foundation first. The components are the easy part.
About Ansh
Frontend engineer with 4+ years building scalable SaaS products, design systems, CRM, analytics and omnichannel platforms.
More about me →