Tailwind CSS Components Tutorial: Build a Production-Ready UI Kit in 2026
Stop copy-pasting random Tailwind snippets from the internet. This tutorial walks you through building a consistent, reusable UI component system with Tailwind CSS v4, class-variance-authority, and React — the same approach used in high-scale production apps.
Most Tailwind CSS tutorials show you how to style a button. Then you end up with twelve slightly different buttons across your app and a stylesheet that nobody dares touch. The real skill isn't knowing Tailwind classes — it's knowing how to build a system on top of them.
This guide walks through building a small but production-ready UI component library using Tailwind CSS v4, React, and class-variance-authority (CVA) — the same approach used in shadcn/ui and most serious React component systems in 2026.
The Problem with Raw Tailwind Classes
Inline Tailwind works great for one-off layouts. It breaks down when you need component variants:
// ❌ The copy-paste trap
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg">Primary</button>
<button className="px-4 py-2 bg-gray-100 text-gray-900 rounded-lg">Secondary</button>
<button className="px-3 py-1.5 bg-red-600 text-white rounded-md text-sm">Danger SM</button>
Three buttons, zero consistency guarantees. When design changes the border radius system, you're doing a find-and-replace across 40 files. There's a better way.
Enter class-variance-authority (CVA)
CVA gives you type-safe variant-based styling — think of it as the missing layer between raw Tailwind and a full component library:
npm install class-variance-authority clsx tailwind-merge
// components/ui/button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
import { clsx } from "clsx";
const buttonVariants = cva(
// Base classes applied to all variants
"inline-flex items-center justify-center gap-2 rounded-lg 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-indigo-600 text-white hover:bg-indigo-700 focus-visible:ring-indigo-600",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-400",
outline: "border border-gray-300 bg-transparent hover:bg-gray-50 text-gray-700",
ghost: "hover:bg-gray-100 text-gray-700",
danger: "bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-600",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-11 px-6 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
export function Button({ variant, size, className, ...props }: ButtonProps) {
return (
<button
className={twMerge(clsx(buttonVariants({ variant, size }), className))}
{...props}
/>
);
}
Now every button in your app is one import away, and design changes cascade automatically:
// Usage is clean and type-safe
<Button variant="primary" size="lg">Deploy</Button>
<Button variant="outline" size="sm">Cancel</Button>
<Button variant="danger">Delete Account</Button>
Building a Card Component System
The same pattern extends to more complex components. Here's a Card with composable sub-components — the pattern popularized by Radix UI and shadcn:
// components/ui/card.tsx
import { twMerge } from "tailwind-merge";
type CardProps = React.HTMLAttributes<HTMLDivElement>;
export function Card({ className, ...props }: CardProps) {
return (
<div
className={twMerge(
"rounded-xl border border-gray-200 bg-white shadow-sm",
className
)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: CardProps) {
return (
<div
className={twMerge("flex flex-col gap-1.5 p-6", className)}
{...props}
/>
);
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={twMerge("text-xl font-semibold leading-tight text-gray-900", className)}
{...props}
/>
);
}
export function CardContent({ className, ...props }: CardProps) {
return <div className={twMerge("p-6 pt-0", className)} {...props} />;
}
export function CardFooter({ className, ...props }: CardProps) {
return (
<div
className={twMerge("flex items-center gap-3 p-6 pt-0", className)}
{...props}
/>
);
}
Design Tokens via CSS Variables (Tailwind v4)
Tailwind v4 moves design tokens to CSS custom properties by default — a huge improvement for theming. Define your palette once in app/globals.css:
@import "tailwindcss";
@theme {
--color-brand-50: oklch(0.97 0.02 264);
--color-brand-500: oklch(0.6 0.2 264);
--color-brand-600: oklch(0.53 0.22 264);
--color-brand-700: oklch(0.45 0.2 264);
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
}
Now bg-brand-500, rounded-md, and all your custom tokens work as standard Tailwind utilities — and you can swap them per-theme with a single CSS override at the :root or [data-theme] level.
Component Library Structure
A clean folder structure that scales:
components/
ui/
button.tsx # CVA variants
card.tsx # Composable sub-components
badge.tsx
input.tsx
dialog.tsx # Radix UI primitive + Tailwind styles
index.ts # Barrel export
lib/
utils.ts # cn() helper (clsx + twMerge)
Actionable Takeaways
- Use class-variance-authority for any component with 2+ variants — it pays for itself on the third button you style
- Combine
clsxandtailwind-mergeinto a singlecn()utility to handle conditional classes and merge conflicts - Build composable sub-components (
Card,CardHeader,CardContent) instead of monolithic props APIs - Migrate to Tailwind v4's CSS
@themetokens — it makes dark mode and white-labeling dramatically simpler - Export everything from a barrel
components/ui/index.tsto keep import paths clean across a large Next.js project
A 200-line component library built this way will scale further than a 2,000-line mess of copied Tailwind classes. Start small, stay consistent, and let TypeScript enforce your design system for you.
Admin
Cal.com
Open source scheduling — self-host your booking system, replace Calendly. Free & privacy-first.
Comments (0)
Sign in to comment
No comments yet. Be the first to comment!