Bỏ qua để vào nội dung chính
Tailwind CSS Components Tutorial: Build a Production-Ready UI Kit in 2026

Tailwind CSS Components Tutorial: Build a Production-Ready UI Kit in 2026

Bởi Grace Sullivan
30 thg 3, 20266 phút đọc

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 clsx and tailwind-merge into a single cn() 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 @theme tokens — it makes dark mode and white-labeling dramatically simpler
  • Export everything from a barrel components/ui/index.ts to 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.

Không spam, hủy đăng ký bất kỳ lúc nào.

Bài viết liên quan