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.
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.
Related articles
- Tailwind Tailwind Component Patterns: Buttons, Cards, and Forms
Practical patterns for building reusable buttons, cards, and form controls in Tailwind — including variants, the @apply debate, and when to extract a component.
- Tailwind Responsive Design with Tailwind CSS
Build responsive layouts with Tailwind CSS: mobile-first breakpoints, responsive utilities, container queries, and practical patterns for real-world responsive components.
- Tailwind Tailwind Component Extraction Strategies
When to keep utilities inline, when to extract a component, and how to use @apply, variants, and cva without painting yourself into a corner.
- Tailwind Tailwind Design System Patterns That Scale
Build a design system on top of Tailwind that stays consistent as the app grows. Tokens, components, variants, and the cva pattern explained.