Skip to content

Tailwind CSS Best Practices: What I Actually Extract Into a Component

Tailwind CSS Best Practices: What I Actually Extract Into a Component

I argued with someone on a project call last fall about whether a button needs to be a component the moment you write its classes a second time. I was the one saying yes. By the end of that project I was the one quietly inlining buttons because the component had grown six variants, three sizes, two icon slots, and an opinion about loading states that nobody asked for.

So here’s where I’ve landed after four years of Tailwind in production, including the v4 migration that ate two weekends I won’t get back. I extract components later than most guides tell you to, and the patterns I do extract are smaller than people expect.

The “rule of three” I no longer trust

The conventional advice is to extract when a pattern repeats three times. That advice is fine for functions. It’s wrong for UI.

Repetition isn’t the right signal, because Tailwind classes already are the abstraction. Repeating flex items-center gap-2 text-sm text-zinc-600 in three places isn’t duplication of meaning. It’s three independent decisions that happen to look similar today. The day one of them needs to be text-zinc-500 you’ll be glad they were separate.

The signal I actually use now is stability. If the pattern hasn’t changed in two months, and three people on the team have touched the surrounding code without breaking it, that’s when I extract. Before that, copy-paste is honest. It tells the next person “yes, these are similar; no, they aren’t required to stay similar.”

What earns a component in my codebase

Three things, and only three.

The first is anything with non-visual behavior baked in. A toast that auto-dismisses. A combobox that owns its open state. A form field that wires up aria-invalid based on validation. The Tailwind classes there are honestly the boring part. The component exists because the behavior needs one place to live, and the styling tags along.

The second is anything where the design team has actually committed to a shape. My Button component exists not because I got tired of writing the classes, but because design picked exactly four variants, two sizes, and a loading spinner that has to look identical on every page. That’s not “looks similar,” that’s a contract.

The third is layout primitives I lean on every day. A Stack that takes a gap prop. A Container that handles max-width and horizontal padding. These have stayed stable across three Tailwind major versions for me, and they save a real amount of typing.

No Card. No Section. No Heading. I tried each of those once and they all got abandoned within a quarter, because the variants outpaced the abstraction.

What stays inline forever (and I’m not sorry)

Most marketing pages. Most settings panels. The empty state on a dashboard. Pretty much any page where the layout is going to get touched twice and then never again.

A real example. I shipped a billing page last month with this pattern repeated six times:

<div className="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
  <h3 className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
    Monthly spend
  </h3>
  <p className="mt-2 text-3xl font-semibold tabular-nums">
    ${amount.toLocaleString()}
  </p>
</div>

A year ago I’d have made <StatCard label amount /> and called it good engineering. This time I left it inline, and when the design team came back two weeks later and asked for a sparkline under “monthly spend” only, I added it in one place and the other five cards stayed identical. If I’d had a component I’d have either added a showSparkline prop or duplicated the component. Both worse.

Variants: cva, tailwind-variants, or just className concat?

If the component is small (button, badge, alert), I use tailwind-variants. Same idea as class-variance-authority but with first-class slot support and a smaller API surface. The migration from cva is about ten minutes per component.

For one-off conditional styling inside a real component, I just write it. clsx or template literals, whichever the file already uses. Reaching for a variant library to toggle one class is overkill, and it pushes the styling logic away from the JSX where everyone else on the team is reading.

Here’s the boring version, which I reach for first:

<button
  className={clsx(
    'rounded-md px-3 py-1.5 text-sm font-medium',
    'transition-colors disabled:opacity-50',
    variant === 'primary' && 'bg-zinc-900 text-white hover:bg-zinc-800',
    variant === 'ghost' && 'text-zinc-700 hover:bg-zinc-100',
  )}
>
  {children}
</button>

When this grows to five variants, three sizes, and a loading state, I migrate to tailwind-variants. Not before. The cost of a variant library is real: a new dependency, a new mental model for whoever opens the file, and a config object that’s easier to skim past than inline classes are.

The @apply trap (and the one time I still use it)

@apply is the feature people reach for when they’re trying to make Tailwind feel like the CSS workflow they’re used to. Almost every time I see it in a codebase, it’s a regression. You lose the ability to grep for “where is this color used,” you lose the variants (@apply hover:bg-blue-500 works but it’s no longer obvious from the JSX), and you end up with a CSS file that nobody on the team wants to touch.

The exception is third-party content. The Tailwind docs on reusing styles warn against @apply for your own components, but they’re quiet about the case where you don’t control the markup. I have a prose override for our blog (rendered Markdown via @tailwindcss/typography) and a small set of @apply rules to style elements I can’t add classes to. That’s it. Maybe twelve lines of CSS across an entire project.

If you’re tempted to use @apply for your own components, write a real React/Vue/Svelte component instead. You’ll thank yourself in six months when you’re trying to add a size="sm" variant and discover you can do it in props instead of a new CSS class.

A note on Tailwind v4 specifically

The v4 migration was rough in places, which I wrote about in my Tailwind v4 migration notes, but the move to CSS-first configuration didn’t change any of the patterns above. If anything it made @apply less attractive, because now your theme tokens are right there in CSS as --color-* variables and you can use them directly. I covered that in my piece on running Tailwind without a config.js.

The one v4-specific habit I picked up: I use CSS variables in places where I used to reach for arbitrary values. bg-[--color-brand] reads better than bg-[#7c3aed], and it survives a design token rename without a find-and-replace.

What to try this week

Pick one Tailwind component in your codebase that you extracted in the last six months and ask honestly: has its API stayed stable, or has it grown a variant prop, then a size prop, then a loading prop, then a leftIcon slot? If it’s grown, you got the timing wrong. Inline it back and see how many places actually used the variants you added. Often it’s one.

I cover this kind of practical front-end work over on my portfolio at abrarqasim.com, and I’m sure I’ll change my mind on at least one of these patterns within the year. Tailwind is good at making you confident about your styling decisions in a way that the next senior engineer who opens your repo doesn’t always share. The best practice I can recommend is staying suspicious of your own abstractions for one more sprint than feels comfortable.