{"id":230,"date":"2026-05-14T05:05:12","date_gmt":"2026-05-14T05:05:12","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/tailwind-css-best-practices-what-i-actually-extract\/"},"modified":"2026-05-14T05:05:12","modified_gmt":"2026-05-14T05:05:12","slug":"tailwind-css-best-practices-what-i-actually-extract","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/tailwind-css-best-practices-what-i-actually-extract\/","title":{"rendered":"Tailwind CSS Best Practices: What I Actually Extract Into a Component"},"content":{"rendered":"<p>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.<\/p>\n<p>So here&rsquo;s where I&rsquo;ve landed after four years of Tailwind in production, including the v4 migration that ate two weekends I won&rsquo;t get back. I extract components later than most guides tell you to, and the patterns I do extract are smaller than people expect.<\/p>\n<h2 id=\"the-rule-of-three-i-no-longer-trust\">The &ldquo;rule of three&rdquo; I no longer trust<\/h2>\n<p>The conventional advice is to extract when a pattern repeats three times. That advice is fine for functions. It&rsquo;s wrong for UI.<\/p>\n<p>Repetition isn&rsquo;t the right signal, because Tailwind classes already are the abstraction. Repeating <code>flex items-center gap-2 text-sm text-zinc-600<\/code> in three places isn&rsquo;t duplication of meaning. It&rsquo;s three independent decisions that happen to look similar today. The day one of them needs to be <code>text-zinc-500<\/code> you&rsquo;ll be glad they were separate.<\/p>\n<p>The signal I actually use now is stability. If the pattern hasn&rsquo;t changed in two months, and three people on the team have touched the surrounding code without breaking it, that&rsquo;s when I extract. Before that, copy-paste is honest. It tells the next person &ldquo;yes, these are similar; no, they aren&rsquo;t required to stay similar.&rdquo;<\/p>\n<h2 id=\"what-earns-a-component-in-my-codebase\">What earns a component in my codebase<\/h2>\n<p>Three things, and only three.<\/p>\n<p>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 <code>aria-invalid<\/code> 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.<\/p>\n<p>The second is anything where the design team has actually committed to a shape. My <code>Button<\/code> 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&rsquo;s not &ldquo;looks similar,&rdquo; that&rsquo;s a contract.<\/p>\n<p>The third is layout primitives I lean on every day. A <code>Stack<\/code> that takes a <code>gap<\/code> prop. A <code>Container<\/code> 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.<\/p>\n<p>No <code>Card<\/code>. No <code>Section<\/code>. No <code>Heading<\/code>. I tried each of those once and they all got abandoned within a quarter, because the variants outpaced the abstraction.<\/p>\n<h2 id=\"what-stays-inline-forever-and-im-not-sorry\">What stays inline forever (and I&rsquo;m not sorry)<\/h2>\n<p>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.<\/p>\n<p>A real example. I shipped a billing page last month with this pattern repeated six times:<\/p>\n<pre><code class=\"language-jsx\">&lt;div className=&quot;rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900&quot;&gt;\n  &lt;h3 className=&quot;text-sm font-medium text-zinc-500 dark:text-zinc-400&quot;&gt;\n    Monthly spend\n  &lt;\/h3&gt;\n  &lt;p className=&quot;mt-2 text-3xl font-semibold tabular-nums&quot;&gt;\n    ${amount.toLocaleString()}\n  &lt;\/p&gt;\n&lt;\/div&gt;\n<\/code><\/pre>\n<p>A year ago I&rsquo;d have made <code>&lt;StatCard label amount \/&gt;<\/code> 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 &ldquo;monthly spend&rdquo; only, I added it in one place and the other five cards stayed identical. If I&rsquo;d had a component I&rsquo;d have either added a <code>showSparkline<\/code> prop or duplicated the component. Both worse.<\/p>\n<h2 id=\"variants-cva-tailwind-variants-or-just-classname-concat\">Variants: cva, tailwind-variants, or just className concat?<\/h2>\n<p>If the component is small (button, badge, alert), I use <a href=\"https:\/\/www.tailwind-variants.org\/\" rel=\"nofollow noopener\" target=\"_blank\"><code>tailwind-variants<\/code><\/a>. Same idea as <a href=\"https:\/\/cva.style\/docs\" rel=\"nofollow noopener\" target=\"_blank\"><code>class-variance-authority<\/code><\/a> but with first-class slot support and a smaller API surface. The migration from <code>cva<\/code> is about ten minutes per component.<\/p>\n<p>For one-off conditional styling inside a real component, I just write it. <code>clsx<\/code> 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.<\/p>\n<p>Here&rsquo;s the boring version, which I reach for first:<\/p>\n<pre><code class=\"language-jsx\">&lt;button\n  className={clsx(\n    'rounded-md px-3 py-1.5 text-sm font-medium',\n    'transition-colors disabled:opacity-50',\n    variant === 'primary' &amp;&amp; 'bg-zinc-900 text-white hover:bg-zinc-800',\n    variant === 'ghost' &amp;&amp; 'text-zinc-700 hover:bg-zinc-100',\n  )}\n&gt;\n  {children}\n&lt;\/button&gt;\n<\/code><\/pre>\n<p>When this grows to five variants, three sizes, and a loading state, I migrate to <code>tailwind-variants<\/code>. 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&rsquo;s easier to skim past than inline classes are.<\/p>\n<h2 id=\"the-apply-trap-and-the-one-time-i-still-use-it\">The @apply trap (and the one time I still use it)<\/h2>\n<p><code>@apply<\/code> is the feature people reach for when they&rsquo;re trying to make Tailwind feel like the CSS workflow they&rsquo;re used to. Almost every time I see it in a codebase, it&rsquo;s a regression. You lose the ability to grep for &ldquo;where is this color used,&rdquo; you lose the variants (<code>@apply hover:bg-blue-500<\/code> works but it&rsquo;s no longer obvious from the JSX), and you end up with a CSS file that nobody on the team wants to touch.<\/p>\n<p>The exception is third-party content. The <a href=\"https:\/\/tailwindcss.com\/docs\/reusing-styles\" rel=\"nofollow noopener\" target=\"_blank\">Tailwind docs on reusing styles<\/a> warn against <code>@apply<\/code> for your own components, but they&rsquo;re quiet about the case where you don&rsquo;t control the markup. I have a <code>prose<\/code> override for our blog (rendered Markdown via <a href=\"https:\/\/github.com\/tailwindlabs\/tailwindcss-typography\" rel=\"nofollow noopener\" target=\"_blank\"><code>@tailwindcss\/typography<\/code><\/a>) and a small set of <code>@apply<\/code> rules to style elements I can&rsquo;t add classes to. That&rsquo;s it. Maybe twelve lines of CSS across an entire project.<\/p>\n<p>If you&rsquo;re tempted to use <code>@apply<\/code> for your own components, write a real React\/Vue\/Svelte component instead. You&rsquo;ll thank yourself in six months when you&rsquo;re trying to add a <code>size=\"sm\"<\/code> variant and discover you can do it in props instead of a new CSS class.<\/p>\n<h2 id=\"a-note-on-tailwind-v4-specifically\">A note on Tailwind v4 specifically<\/h2>\n<p>The v4 migration was rough in places, which I wrote about in <a href=\"https:\/\/abrarqasim.com\/blog\/migrating-to-tailwind-v4-what-broke-what-i-wish-id-known\" rel=\"noopener\">my Tailwind v4 migration notes<\/a>, but the move to CSS-first configuration didn&rsquo;t change any of the patterns above. If anything it made <code>@apply<\/code> less attractive, because now your theme tokens are right there in CSS as <code>--color-*<\/code> variables and you can use them directly. I covered that in <a href=\"https:\/\/abrarqasim.com\/blog\/tailwind-config-v4-six-months-without-config-js\" rel=\"noopener\">my piece on running Tailwind without a config.js<\/a>.<\/p>\n<p>The one v4-specific habit I picked up: I use CSS variables in places where I used to reach for arbitrary values. <code>bg-[--color-brand]<\/code> reads better than <code>bg-[#7c3aed]<\/code>, and it survives a design token rename without a find-and-replace.<\/p>\n<h2 id=\"what-to-try-this-week\">What to try this week<\/h2>\n<p>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 <code>variant<\/code> prop, then a <code>size<\/code> prop, then a <code>loading<\/code> prop, then a <code>leftIcon<\/code> slot? If it&rsquo;s grown, you got the timing wrong. Inline it back and see how many places actually used the variants you added. Often it&rsquo;s one.<\/p>\n<p>I cover this kind of practical front-end work over on <a href=\"https:\/\/abrarqasim.com\/about\" rel=\"noopener\">my portfolio at abrarqasim.com<\/a>, and I&rsquo;m sure I&rsquo;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&rsquo;t always share. The best practice I can recommend is staying suspicious of your own abstractions for one more sprint than feels comfortable.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>After 4 years with Tailwind, I extract components way later than guides suggest. Here&#8217;s what earns a real component, what stays inline, and why my CSS file is 12 lines.<\/p>\n","protected":false},"author":2,"featured_media":229,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"After 4 years with Tailwind, I extract components way later than guides suggest. Here's what earns a real component, what stays inline, and why my CSS file is 12 lines.","rank_math_focus_keyword":"tailwind css best practices","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[138,35],"tags":[51,266,37,38,139,36,39],"class_list":["post-230","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-frontend","category-web-development","tag-best-practices","tag-components","tag-css","tag-frontend","tag-tailwind","tag-tailwind-css","tag-web-development"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/230","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=230"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/230\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/229"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=230"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=230"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=230"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}