Skip to content
Codeloom
Tailwind

Building Custom Components with Tailwind

Extract reusable components from Tailwind utility classes: @apply, component extraction patterns, the plugin API, design tokens, and strategies for maintaining consistency at scale.

·7 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • When and how to use @apply for component classes
  • Component extraction patterns in React and HTML
  • Building custom Tailwind plugins
  • Design tokens and theme configuration
  • Maintaining consistency as your project grows

Prerequisites

  • Basic Tailwind CSS utility class knowledge
  • Familiarity with a component framework (React, Vue, etc.)
  • Basic CSS understanding

Tailwind’s utility-first approach works well for building individual elements, but real projects need reusable patterns. A button, a card, an input group, a badge — you do not want to copy-paste 15 utility classes every time. This guide covers the strategies for extracting reusable components while keeping Tailwind’s benefits.

Strategy 1: Framework components (preferred)

If you are using React, Vue, Svelte, or any component framework, the best way to reuse Tailwind styles is to extract a component.

// Button.tsx
interface ButtonProps {
  variant?: "primary" | "secondary" | "danger";
  size?: "sm" | "md" | "lg";
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
  className?: string;
}

const variants = {
  primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
  secondary: "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-blue-500",
  danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
};

const sizes = {
  sm: "px-3 py-1.5 text-sm",
  md: "px-4 py-2 text-base",
  lg: "px-6 py-3 text-lg",
};

export function Button({
  variant = "primary",
  size = "md",
  children,
  onClick,
  disabled,
  className = "",
}: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`
        inline-flex items-center justify-center rounded-md font-medium
        transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2
        disabled:opacity-50 disabled:cursor-not-allowed
        ${variants[variant]}
        ${sizes[size]}
        ${className}
      `.trim()}
    >
      {children}
    </button>
  );
}

Usage:

<Button variant="primary" size="lg" onClick={handleSubmit}>
  Submit
</Button>
<Button variant="secondary" size="sm">
  Cancel
</Button>
<Button variant="danger" onClick={handleDelete}>
  Delete account
</Button>

Card component

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

const paddings = {
  none: "",
  sm: "p-4",
  md: "p-6",
  lg: "p-8",
};

export function Card({ children, padding = "md", className = "" }: CardProps) {
  return (
    <div className={`rounded-lg bg-white shadow ${paddings[padding]} ${className}`}>
      {children}
    </div>
  );
}

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

export function CardTitle({ children }: { children: React.ReactNode }) {
  return <h3 className="text-lg font-semibold text-gray-900">{children}</h3>;
}

export function CardContent({ children }: { children: React.ReactNode }) {
  return <div className="text-gray-600">{children}</div>;
}
<Card>
  <CardHeader>
    <CardTitle>User Settings</CardTitle>
  </CardHeader>
  <CardContent>
    <p>Manage your account preferences here.</p>
  </CardContent>
</Card>

Input component

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

export function Input({ label, error, helperText, id, className = "", ...props }: InputProps) {
  const inputId = id || label.toLowerCase().replace(/\s+/g, "-");
  
  return (
    <div className="space-y-1">
      <label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
        {label}
      </label>
      <input
        id={inputId}
        className={`
          block w-full rounded-md border px-3 py-2 text-gray-900
          placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-0
          ${error
            ? "border-red-300 focus:border-red-500 focus:ring-red-500"
            : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
          }
          ${className}
        `.trim()}
        {...props}
      />
      {error && <p className="text-sm text-red-600">{error}</p>}
      {helperText && !error && <p className="text-sm text-gray-500">{helperText}</p>}
    </div>
  );
}

Strategy 2: @apply

@apply lets you compose utility classes into a custom CSS class. Use it for elements where you cannot use a component (plain HTML, CMS content, markdown output).

/* styles/components.css */
@layer components {
  .btn {
    @apply inline-flex items-center justify-center rounded-md font-medium
           transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2
           disabled:opacity-50 disabled:cursor-not-allowed;
  }

  .btn-primary {
    @apply btn bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500;
  }

  .btn-secondary {
    @apply btn bg-white text-gray-700 border border-gray-300
           hover:bg-gray-50 focus:ring-blue-500;
  }

  .btn-sm {
    @apply px-3 py-1.5 text-sm;
  }

  .btn-md {
    @apply px-4 py-2 text-base;
  }

  .btn-lg {
    @apply px-6 py-3 text-lg;
  }
}
<button class="btn-primary btn-lg">Submit</button>

When to use @apply

  • Styling content you do not control (CMS output, markdown, third-party widgets)
  • Plain HTML projects without a component framework
  • Very small utility sets that you repeat many times in HTML

When NOT to use @apply

  • When you have a component framework available (use a component instead)
  • For one-off styles (just use utilities directly)
  • As a way to avoid learning utilities (defeats the purpose of Tailwind)

Strategy 3: Tailwind plugins

Plugins extend Tailwind with custom utilities, components, or variants.

// tailwind.config.js
const plugin = require("tailwindcss/plugin");

module.exports = {
  plugins: [
    plugin(function ({ addComponents, theme }) {
      addComponents({
        ".card": {
          backgroundColor: theme("colors.white"),
          borderRadius: theme("borderRadius.lg"),
          boxShadow: theme("boxShadow.DEFAULT"),
          padding: theme("spacing.6"),
        },
        ".card-header": {
          borderBottom: `1px solid ${theme("colors.gray.200")}`,
          marginBottom: theme("spacing.4"),
          paddingBottom: theme("spacing.4"),
        },
        ".badge": {
          display: "inline-flex",
          alignItems: "center",
          borderRadius: theme("borderRadius.full"),
          padding: `${theme("spacing.1")} ${theme("spacing.3")}`,
          fontSize: theme("fontSize.xs"),
          fontWeight: theme("fontWeight.medium"),
        },
        ".badge-green": {
          backgroundColor: theme("colors.green.100"),
          color: theme("colors.green.800"),
        },
        ".badge-red": {
          backgroundColor: theme("colors.red.100"),
          color: theme("colors.red.800"),
        },
        ".badge-blue": {
          backgroundColor: theme("colors.blue.100"),
          color: theme("colors.blue.800"),
        },
      });
    }),
  ],
};

Custom utilities via plugin

plugin(function ({ addUtilities }) {
  addUtilities({
    ".text-balance": {
      "text-wrap": "balance",
    },
    ".scrollbar-hide": {
      "-ms-overflow-style": "none",
      "scrollbar-width": "none",
      "&::-webkit-scrollbar": {
        display: "none",
      },
    },
    ".drag-none": {
      "-webkit-user-drag": "none",
      "user-drag": "none",
    },
  });
});

Custom variants

plugin(function ({ addVariant }) {
  // Matches when parent has a specific data attribute
  addVariant("data-active", '&[data-active="true"]');
  
  // Matches based on parent state
  addVariant("group-checked", ":merge(.group):checked ~ &");
});

Usage:

<div data-active="true" class="bg-gray-100 data-active:bg-blue-100">
  Active state styling
</div>

Design tokens in tailwind.config.js

Centralize your design system values in the Tailwind config.

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          50: "#eff6ff",
          100: "#dbeafe",
          200: "#bfdbfe",
          300: "#93c5fd",
          400: "#60a5fa",
          500: "#3b82f6",
          600: "#2563eb",
          700: "#1d4ed8",
          800: "#1e40af",
          900: "#1e3a8a",
        },
        success: "#16a34a",
        warning: "#ca8a04",
        error: "#dc2626",
      },
      fontFamily: {
        sans: ["Inter", "system-ui", "sans-serif"],
        mono: ["JetBrains Mono", "monospace"],
      },
      spacing: {
        18: "4.5rem",
        88: "22rem",
        128: "32rem",
      },
      borderRadius: {
        "4xl": "2rem",
      },
      animation: {
        "fade-in": "fadeIn 0.3s ease-in-out",
        "slide-up": "slideUp 0.3s ease-out",
      },
      keyframes: {
        fadeIn: {
          "0%": { opacity: "0" },
          "100%": { opacity: "1" },
        },
        slideUp: {
          "0%": { transform: "translateY(10px)", opacity: "0" },
          "100%": { transform: "translateY(0)", opacity: "1" },
        },
      },
    },
  },
};

Now your design tokens are available as utilities:

<div class="bg-brand-500 text-white font-sans rounded-4xl animate-fade-in">
  Brand styled content
</div>

Class merging with clsx/tailwind-merge

When building components that accept a className prop, class conflicts can occur. Use tailwind-merge to resolve them.

import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

// Usage in a component
function Alert({ variant, className, children }: AlertProps) {
  return (
    <div className={cn(
      "rounded-md p-4 text-sm",
      variant === "error" && "bg-red-50 text-red-800",
      variant === "success" && "bg-green-50 text-green-800",
      className  // User overrides win
    )}>
      {children}
    </div>
  );
}

// The user can override specific utilities
<Alert variant="error" className="p-6 rounded-lg">
  {/* p-6 overrides p-4, rounded-lg overrides rounded-md */}
  Something went wrong
</Alert>

Scaling patterns

As your project grows, organize components in a structure that keeps things findable.

src/
  components/
    ui/
      Button.tsx
      Card.tsx
      Input.tsx
      Badge.tsx
      Modal.tsx
    layout/
      Header.tsx
      Footer.tsx
      Sidebar.tsx
    features/
      auth/
        LoginForm.tsx
        SignupForm.tsx
      dashboard/
        StatsCard.tsx
        Chart.tsx

Keep your low-level UI components (Button, Card, Input) separate from feature components. The UI components use only Tailwind utilities and have no business logic. Feature components compose UI components and add behavior.

The best Tailwind component strategy depends on your project. Framework components for React/Vue/Svelte projects. @apply for plain HTML or CMS content. Plugins for utilities and variants you want available everywhere. Design tokens in the config for consistency. Pick the right tool for each situation and your codebase stays clean as it grows.