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>
);
}