Building a design system with Tailwind that actually works
How I approach component design and consistency without over-engineering a system nobody asked for.

Every project I start eventually reaches the point where things start looking inconsistent. Buttons have three different sizes depending on which page I built first. Spacing between sections varies by a few pixels. Colors drift because I eyeballed a shade instead of using a token.
This is when most people reach for a design system. They create a Figma library, document every component variant, write usage guidelines. Two weeks later, they have a beautiful system and no product.
Here's how I actually handle this without losing momentum.
Start with constraints, not components
The biggest mistake I see is starting with components. People build a Button component with twelve variants before they know what their app looks like. Then they spend the next month fitting their UI into the constraints of components they built too early.
Instead, I start with constraints. Specifically, I define three things before writing any component code:
A type scale. I pick 5-6 font sizes and stick to them. In Tailwind terms, that's usually text-sm, text-base, text-lg, text-xl, text-2xl, and occasionally text-3xl. If I need something outside this range, I question why.
A spacing scale. Tailwind's default spacing scale is already good, but I limit myself to a subset. Most of my spacing uses gap-2, gap-4, gap-6, gap-8, and gap-12. Consistent spacing is the single biggest factor in making a UI look designed rather than assembled.
A color palette. I define my colors in globals.css using CSS custom properties. Background, foreground, muted, border, and one accent color. That's five colors. Ninety percent of any interface can be built with five colors.
These three constraints do more for consistency than any component library.
Components emerge from repetition
I don't pre-build components. I build the same thing twice, notice the repetition, and then extract a component.
The first time I need a card layout, I write the HTML and Tailwind classes inline. The second time, I notice I'm writing the same structure and extract it. The third time, I realize it needs a variant and add that.
This approach means every component exists because it was needed, not because I imagined it might be useful. The component's API reflects actual usage patterns rather than theoretical flexibility.
With shadcn/ui, I get a head start on common components. But I treat them as starting points, not finished products. I regularly modify the components I've pulled in to match my specific needs rather than working around their defaults.
The Tailwind approach to variants
One thing Tailwind does brilliantly is make variants explicit. When I look at a component, I can see every style applied to it. There's no hidden cascade, no specificity battles, no "where is this margin coming from?"
For component variants, I use cva (class-variance-authority). It gives me a clean way to define variants that maps directly to Tailwind classes:
const button = cva("inline-flex items-center rounded-md font-medium", {
variants: {
variant: {
default: "bg-primary text-primary-foreground",
ghost: "hover:bg-accent",
},
size: {
sm: "h-8 px-3 text-xs",
md: "h-9 px-4 text-sm",
},
},
});
This is the entire styling logic for a button. No separate CSS file, no theme object, no style props. Just classes.
Dark mode without the pain
Dark mode used to be a major effort. With Tailwind's dark: variant and CSS custom properties, it's almost free.
I define my color tokens as CSS properties that change in dark mode:
:root {
--background: oklch(100% 0 0);
--foreground: oklch(10% 0 0);
}
.dark {
--background: oklch(14% 0.005 250);
--foreground: oklch(98% 0 0);
}
Then in my Tailwind config, these map to semantic color names: bg-background, text-foreground, border-border. Components automatically adapt to dark mode because they reference tokens, not raw colors.
The key insight is using semantic names rather than color names. text-muted-foreground works in both themes. text-gray-500 only works in one.
When to stop
The hardest part of building a design system is knowing when to stop. It's tempting to keep adding variants, documenting edge cases, and refining tokens. But a design system is a tool for building products, not a product itself.
My rule is: if I haven't needed a variant in two different places, it doesn't exist yet. If a component has more than three variants, I question whether it should be one component or two.
The goal is consistency, not completeness. A small, focused system that covers 90% of cases is better than a comprehensive system that takes 10x longer to build and maintain.
Keep it simple. Ship the product. Refine the system as you go.
Keep reading
Want to work together?
I help companies design and build products from the ground up. Let's talk about your project.
Get in touch