Reusable Components (React & Blade)
By now you have noticed the one real downside of utility classes: a button or card can need fifteen classes, and a site has dozens of buttons and cards. Copying the same long class list everywhere is slow and risky; change the design and you must find every copy.
The answer is not to hide the classes in CSS. It is to write the markup once and reuse it, using the tools your project already has: loops, template functions, React components or Laravel Blade components. In this lesson you will learn these patterns, how to handle variants such as "primary" and "danger" without breaking Tailwind's class detection, and how to let a component accept extra classes safely.
Start with loops
Much of the repetition on a page is a list: products, students, notices. Put the data in an array and loop over it, and the class list exists in one place even though it appears many times on screen.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cards from data</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
</head>
<body class="bg-slate-100 p-6">
<div id="courses" class="grid max-w-3xl gap-4 sm:grid-cols-3"></div>
<script>
const courses = [
{ title: 'Python basics', city: 'Pune', level: 'beginner', fee: 4999 },
{ title: 'React projects', city: 'Kochi', level: 'advanced', fee: 8999 },
{ title: 'SQL for analysts', city: 'Jaipur', level: 'intermediate', fee: 5999 },
];
// Every class name is written in full, so Tailwind can find it.
const levelClasses = {
beginner: 'bg-green-100 text-green-800',
intermediate: 'bg-amber-100 text-amber-800',
advanced: 'bg-rose-100 text-rose-800',
};
const list = document.getElementById('courses');
for (const course of courses) {
const card = document.createElement('article');
card.className = 'rounded-xl bg-white p-4 shadow-sm';
card.innerHTML = `
<span class="rounded-full px-2 py-0.5 text-xs font-medium ${levelClasses[course.level]}"></span>
<h3 class="mt-2 font-semibold text-slate-900"></h3>
<p class="text-sm text-slate-600"></p>`;
card.querySelector('span').textContent = course.level;
card.querySelector('h3').textContent = course.title;
card.querySelector('p').textContent = `${course.city} · ₹${course.fee.toLocaleString('en-IN')}`;
list.append(card);
}
</script>
</body>
</html>Two habits in this code matter. First, the variant map levelClasses holds complete class names, never pieces like 'bg-' + colour. Second, text from data goes in with textContent, not inside the HTML string, so a course title can never inject HTML into the page.
Variants with full class strings
Most components come in a few versions: a primary and a secondary button, small and large sizes. Keep a shared base and a map for each choice:
const base = 'inline-flex items-center justify-center rounded-lg font-semibold focus-visible:outline-2 focus-visible:outline-offset-2';
const variants = {
primary: 'bg-sky-600 text-white hover:bg-sky-700 focus-visible:outline-sky-600',
secondary: 'bg-white text-slate-900 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-slate-600',
danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600',
};
const sizes = { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2', lg: 'px-6 py-3 text-lg' };
function buttonClasses(variant = 'primary', size = 'md') {
return [base, variants[variant], sizes[size]].join(' ');
}Because every class is written in full in the file, Tailwind generates all of them. This exact pattern carries over to every framework.
React components
In React the component is the reusable unit, so the class list lives in one function. Here is a Button component using the same maps (React uses className instead of class):
const variants = {
primary: 'bg-sky-600 text-white hover:bg-sky-700',
danger: 'bg-red-600 text-white hover:bg-red-700',
};
export function Button({ variant = 'primary', className = '', ...props }) {
return (
<button
className={`rounded-lg px-4 py-2 font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 ${variants[variant]} ${className}`}
{...props}
/>
);
}
// Usage
<Button onClick={save}>Save marks</Button>
<Button variant="danger" className="w-full">Delete student</Button>The className prop lets the caller add layout classes such as w-full or mt-4. Two popular community packages help here: clsx builds class strings from conditions (clsx('p-4', isActive && 'bg-sky-50')), and tailwind-merge removes conflicting classes, so a caller's px-6 reliably replaces the component's px-4. Without it, px-4 px-6 on one element gives an unpredictable result, because the order in the CSS file decides, not the order in the attribute.
Laravel Blade components
Laravel's Blade templates have components too, and new Laravel apps come with Tailwind and Vite already set up. A component is a file in resources/views/components; the file button.blade.php becomes the <x-button> tag:
@props(['variant' => 'primary'])
@php
$variants = [
'primary' => 'bg-sky-600 text-white hover:bg-sky-700',
'danger' => 'bg-red-600 text-white hover:bg-red-700',
];
@endphp
<button {{ $attributes->merge(['class' => 'rounded-lg px-4 py-2 font-semibold ' . $variants[$variant]]) }}>
{{ $slot }}
</button>Use it in any view:
<x-button>Save marks</x-button>
<x-button variant="danger" class="w-full" type="submit">Delete student</x-button>@props declares the variant option with a default. $attributes->merge() joins the component's classes with any class the caller passes, and keeps other attributes such as type="submit". {{ $slot }} is the content between the tags. Blade also has $attributes->class([...]) for conditional classes, such as ['ring-2 ring-sky-500' => $selected]. A plain PHP site can do the same with a small function or an include that receives variables.
A Tailwind-only option: data attributes
When you have no component system at all, for example in static HTML, you can drive variants with a data-* attribute and Tailwind's data variants. The class list is the same for every button; the attribute picks the style:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Data attribute variants</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
</head>
<body class="flex flex-wrap gap-3 p-8">
<button data-variant="primary" class="rounded-lg px-4 py-2 font-semibold data-[variant=danger]:bg-red-600 data-[variant=danger]:text-white data-[variant=ghost]:text-slate-700 data-[variant=ghost]:ring-1 data-[variant=ghost]:ring-slate-300 data-[variant=primary]:bg-sky-600 data-[variant=primary]:text-white">Pay fees</button>
<button data-variant="ghost" class="rounded-lg px-4 py-2 font-semibold data-[variant=danger]:bg-red-600 data-[variant=danger]:text-white data-[variant=ghost]:text-slate-700 data-[variant=ghost]:ring-1 data-[variant=ghost]:ring-slate-300 data-[variant=primary]:bg-sky-600 data-[variant=primary]:text-white">Download receipt</button>
<button data-variant="danger" class="rounded-lg px-4 py-2 font-semibold data-[variant=danger]:bg-red-600 data-[variant=danger]:text-white data-[variant=ghost]:text-slate-700 data-[variant=ghost]:ring-1 data-[variant=ghost]:ring-slate-300 data-[variant=primary]:bg-sky-600 data-[variant=primary]:text-white">Cancel admission</button>
</body>
</html>This is verbose, but every button is identical apart from data-variant, so you could copy one class list into a snippet in your editor. If a site grows beyond a few pages, move to real components.
Common mistakes
- Building class names from props.
`bg-${colour}-600`is never generated by a real build. Map each option to complete class names. Be careful when testing: the browser build watches the live page, so such classes can seem to work there and then break afternpm run build. - Using
@applyto shorten markup. It hides styles in another file and grows your CSS; prefer components. See Custom Utilities, Variants & @apply. - Letting callers pass conflicting classes.
px-4from the component pluspx-6from the caller is unpredictable. Offer a size prop, or merge classes withtailwind-merge. - Putting data inside HTML strings. Use
textContent(or the framework's normal output, which escapes text) so user data cannot become HTML.
Key points
- Reuse markup, not CSS: loops, template functions and components keep each class list in one place.
- Store variants in maps of complete class strings so Tailwind can detect every class.
- React components take props such as
variantand an optionalclassName;clsxandtailwind-mergehelp combine classes. - Blade components use
@props,$attributes->merge(['class' => ...])and{{ $slot }}. - Without a component system,
data-*variants can switch styles from one shared class list.
Exercise
Write a JavaScript function badge(status) for an exam portal in Lucknow. It should return a <span> element for the statuses passed, failed and pending, with a shared base style (rounded, small text, padding) and a different colour pair for each status taken from a map of full class names. Use it to show three badges on a page, with the text set through textContent.
Show solution
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Status badges</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
</head>
<body class="flex gap-3 p-8">
<script>
const statusClasses = {
passed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
pending: 'bg-slate-100 text-slate-700',
};
function badge(status) {
const span = document.createElement('span');
span.className = `rounded-full px-3 py-1 text-sm font-medium ${statusClasses[status]}`;
span.textContent = status;
return span;
}
document.body.append(badge('passed'), badge('failed'), badge('pending'));
</script>
</body>
</html>The colour classes live in one map as complete strings, so Tailwind generates all of them, and each badge is created by the same function.
Quick Quiz
1. What is the recommended way to avoid repeating a long Tailwind class list for every button?
Components keep the markup and its classes in one place without hiding them in CSS.
2. Why does `text-${colour}-700` not work in a component?
Tailwind detects classes as text; class names built at run time must be written in full somewhere, such as in a map.
3. In a Blade component, what does $attributes->merge(['class' => '...']) do?
merge joins the default classes with the caller's class attribute and keeps other attributes like type.
4. What problem does the tailwind-merge package solve?
When two classes set the same property, the CSS order decides; tailwind-merge keeps only the last one in the list.
Learn Tailwind CSS with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
