Skip to main content
Tailwind CSS Mastery·Lesson 4 of 5

Component Patterns

As your project grows, repeating the same long strings of utility classes becomes a maintenance burden. Tailwind offers several strategies for creating reusable patterns without abandoning the utility-first approach.

Strategy 1: Framework Components

The best way to avoid repetition is to extract a component in your framework. The utility classes live in one place and you reuse the component:

interface ButtonProps {
  variant?: "primary" | "secondary" | "danger";
  size?: "sm" | "md" | "lg";
  children: React.ReactNode;
  onClick?: () => void;
}

const styles = {
  base: "inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2",
  variant: {
    primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
    secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500",
    danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
  },
  size: {
    sm: "px-3 py-1.5 text-sm",
    md: "px-4 py-2 text-sm",
    lg: "px-6 py-3 text-base",
  },
};

export function Button({ variant = "primary", size = "md", children, onClick }: ButtonProps) {
  return (
    <button
      onClick={onClick}
      className={`${styles.base} ${styles.variant[variant]} ${styles.size[size]}`}
    >
      {children}
    </button>
  );
}

Usage is clean and consistent:

<Button variant="primary" size="lg">Save Changes</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="danger" size="sm">Delete</Button>

Strategy 2: @apply for Repeated Markup

When you cannot extract a framework component (e.g., styling Markdown output or third-party HTML), use @apply to compose utilities into a CSS class:

/* Only use @apply when you truly cannot use a component */
.prose-custom h2 {
  @apply text-2xl font-bold text-gray-900 dark:text-gray-100 mt-10 mb-4;
}

.prose-custom p {
  @apply text-gray-700 dark:text-gray-300 leading-relaxed mb-4;
}

.prose-custom code {
  @apply bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm font-mono;
}

.prose-custom a {
  @apply text-blue-600 dark:text-blue-400 underline hover:no-underline;
}

When to use @apply: styling content you do not control (CMS output, Markdown, third-party widgets). When not to: if you can use a component, always prefer that.

Strategy 3: Utility Helper with clsx

Use clsx (or tailwind-merge) to conditionally compose classes cleanly:

import { clsx } from "clsx";

interface BadgeProps {
  status: "success" | "warning" | "error" | "info";
  children: React.ReactNode;
}

export function Badge({ status, children }: BadgeProps) {
  return (
    <span
      className={clsx(
        "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
        {
          "bg-green-100 text-green-800": status= "success",
          "bg-yellow-100 text-yellow-800": status= "warning",
          "bg-red-100 text-red-800": status= "error",
          "bg-blue-100 text-blue-800": status= "info",
        }
      )}
    >
      {children}
    </span>
  );
}

Strategy 4: Tailwind Plugins

For utilities that Tailwind does not ship by default, write a plugin:

// tailwind-plugin-scrollbar.ts
import plugin from "tailwindcss/plugin";

export const scrollbarPlugin = plugin(function ({ addUtilities }) {
  addUtilities({
    ".scrollbar-thin": {
      "scrollbar-width": "thin",
      "&::-webkit-scrollbar": {
        width: "6px",
        height: "6px",
      },
    },
    ".scrollbar-none": {
      "scrollbar-width": "none",
      "&::-webkit-scrollbar": {
        display: "none",
      },
    },
  });
});

In Tailwind CSS 4, import and use the plugin in your CSS:

@import "tailwindcss";
@plugin "./tailwind-plugin-scrollbar.ts";

Now use the utilities directly:

<div class="overflow-auto scrollbar-thin h-64">
  <!-- Scrollable content with thin scrollbar -->
</div>

Reusable Card Pattern

Here is a practical card component that combines multiple patterns:

import { clsx } from "clsx";

interface CardProps {
  children: React.ReactNode;
  padding?: "sm" | "md" | "lg";
  hover?: boolean;
  className?: string;
}

export function Card({ children, padding = "md", hover = false, className }: CardProps) {
  return (
    <div
      className={clsx(
        "bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700",
        {
          "p-4": padding= "sm",
          "p-6": padding= "md",
          "p-8": padding= "lg",
          "transition-shadow hover:shadow-lg cursor-pointer": hover,
        },
        className
      )}
    >
      {children}
    </div>
  );
}

function CardHeader({ children }: { children: React.ReactNode }) {
  return <div className="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">{children}</div>;
}

function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="text-gray-600 dark:text-gray-400">{children}</div>;
}

Card.Header = CardHeader;
Card.Body = CardBody;
<Card hover>
  <Card.Header>
    <h3 className="text-lg font-semibold text-gray-900 dark:text-white">Analytics</h3>
  </Card.Header>
  <Card.Body>
    <p>Your site had 12,340 visitors this week.</p>
  </Card.Body>
</Card>

Input Component with Variants

import { clsx } from "clsx";

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
}

export function Input({ label, error, className, ...props }: InputProps) {
  return (
    <div>
      <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
        {label}
      </label>
      <input
        className={clsx(
          "block w-full rounded-lg border px-3 py-2 text-sm transition-colors",
          "focus:outline-none focus:ring-2 focus:ring-offset-2",
          error
            ? "border-red-500 focus:ring-red-500 text-red-900 placeholder-red-400"
            : "border-gray-300 dark:border-gray-600 focus:ring-blue-500 dark:bg-gray-800 dark:text-white",
          className
        )}
        {...props}
      />
      {error && <p className="mt-1 text-sm text-red-600">{error}</p>}
    </div>
  );
}