{"id":579,"date":"2026-08-15T13:01:24","date_gmt":"2026-08-15T13:01:24","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/tailwind-v4-utility-directive-the-apply-habit-i-broke\/"},"modified":"2026-08-15T13:01:24","modified_gmt":"2026-08-15T13:01:24","slug":"tailwind-v4-utility-directive-the-apply-habit-i-broke","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/tailwind-v4-utility-directive-the-apply-habit-i-broke\/","title":{"rendered":"The @apply Habit I Broke Moving to Tailwind v4"},"content":{"rendered":"<p>I spent about two hours last month trying to work out why <code>@apply btn-primary<\/code> was throwing &ldquo;Cannot apply unknown utility class&rdquo; when <code>btn-primary<\/code> was very obviously defined about nine lines above it in the same file.<\/p>\n<p>The answer, once I found it, made me feel stupid and then made me feel a bit better about my own architecture. Tailwind v4 doesn&rsquo;t know about classes you define in <code>@layer components<\/code>. If you want <code>@apply<\/code> to see a class, it has to be registered as a utility, which means <code>@utility<\/code>. That&rsquo;s it. That&rsquo;s the whole bug.<\/p>\n<p>But the fix I ended up shipping wasn&rsquo;t &ldquo;swap <code>@layer components<\/code> for <code>@utility<\/code>.&rdquo; It was deleting most of those classes entirely, which is a thing I&rsquo;d been putting off for roughly three years.<\/p>\n<h2 id=\"the-habit-id-built-and-why-v4-broke-it\">The habit I&rsquo;d built and why v4 broke it<\/h2>\n<p>Here&rsquo;s the pattern I&rsquo;d been writing since about 2021:<\/p>\n<pre><code class=\"language-css\">\/* Tailwind v3 *\/\n@layer components {\n  .btn {\n    @apply inline-flex items-center rounded-md px-4 py-2 font-medium;\n  }\n  .btn-primary {\n    @apply btn bg-indigo-600 text-white hover:bg-indigo-700;\n  }\n  .btn-ghost {\n    @apply btn bg-transparent text-indigo-600 hover:bg-indigo-50;\n  }\n}\n<\/code><\/pre>\n<p>Reasonable-looking. It kept the markup tidy. It also quietly reinvented Bootstrap inside a utility framework, which is the joke everyone makes and which I was too close to my own codebase to notice.<\/p>\n<p>In v4, <code>.btn-primary<\/code> composing <code>.btn<\/code> via <code>@apply<\/code> doesn&rsquo;t work, because <code>.btn<\/code> isn&rsquo;t a utility as far as the engine is concerned. You can fix it directly:<\/p>\n<pre><code class=\"language-css\">\/* Tailwind v4 *\/\n@utility btn {\n  display: inline-flex;\n  align-items: center;\n  border-radius: var(--radius-md);\n  padding-inline: --spacing(4);\n  padding-block: --spacing(2);\n  font-weight: 500;\n}\n<\/code><\/pre>\n<p>Now <code>btn<\/code> is a real utility, works with variants like <code>hover:<\/code> and <code>lg:<\/code>, and <code>@apply btn<\/code> resolves. The <a href=\"https:\/\/tailwindcss.com\/docs\/functions-and-directives\" rel=\"nofollow noopener\" target=\"_blank\">functions and directives reference<\/a> has the full list of what <code>@utility<\/code> accepts.<\/p>\n<p>That&rsquo;s the mechanical fix, and if you have a big v3 codebase and a deadline, take it and move on. I did it for about a third of my classes.<\/p>\n<h2 id=\"why-i-deleted-the-rest-instead\">Why I deleted the rest instead<\/h2>\n<p>Once I&rsquo;d converted a few by hand, I noticed something uncomfortable. Almost every one of those component classes existed because a React component already existed. I had a <code>&lt;Button&gt;<\/code> component. I also had a <code>.btn-primary<\/code> class. Two places encoding the same design decision, and every time a designer changed the padding I had to remember both.<\/p>\n<p>So the variant logic moved into the component, where it was already half living:<\/p>\n<pre><code class=\"language-jsx\">const styles = {\n  base: 'inline-flex items-center rounded-md px-4 py-2 font-medium',\n  primary: 'bg-indigo-600 text-white hover:bg-indigo-700',\n  ghost: 'bg-transparent text-indigo-600 hover:bg-indigo-50',\n}\n\nexport function Button({ variant = 'primary', className, ...props }) {\n  return (\n    &lt;button\n      className={cn(styles.base, styles[variant], className)}\n      {...props}\n    \/&gt;\n  )\n}\n<\/code><\/pre>\n<p>Nothing clever. A lookup object and a class merger. If you want the typed version with compound variants, <code>class-variance-authority<\/code> does this properly and I use it on anything with more than three variants, but for a button this is enough.<\/p>\n<p>The thing I like: there&rsquo;s now exactly one place that knows what a primary button looks like, and it&rsquo;s the file named <code>Button.jsx<\/code>. The CSS file went from 340 lines to about 40.<\/p>\n<h2 id=\"when-utility-is-still-the-right-answer\">When @utility is still the right answer<\/h2>\n<p>I&rsquo;m not arguing you should never write custom CSS. Three cases where I still reach for <code>@utility<\/code>:<\/p>\n<p>Genuinely reusable primitives that aren&rsquo;t components. <code>tab-4<\/code>, a text-balance shim, a scrollbar-gutter helper. Things that are one property and belong in the utility vocabulary.<\/p>\n<p>Styling markup you don&rsquo;t control. Third party widgets, CMS output, anything where you can&rsquo;t add classes to the element. <code>@apply<\/code> inside a plain CSS selector is exactly right here, and the docs use a Select2 dropdown as the example for good reason.<\/p>\n<p>Design tokens, which aren&rsquo;t <code>@utility<\/code> at all but <code>@theme<\/code>. This is the part of v4 I&rsquo;d argue is the actual improvement:<\/p>\n<pre><code class=\"language-css\">@theme {\n  --color-brand-500: oklch(0.62 0.19 264);\n  --radius-card: 0.75rem;\n}\n<\/code><\/pre>\n<p>Defining a theme variable doesn&rsquo;t just create a CSS variable, it creates the corresponding utility classes. <code>bg-brand-500<\/code> and <code>rounded-card<\/code> now exist. That&rsquo;s a much better home for design decisions than a JavaScript config object, and it&rsquo;s the reason my <code>tailwind.config.js<\/code> is gone. I wrote about the fallout from that in <a href=\"https:\/\/abrarqasim.com\/blog\/tailwind-v4-migration-what-broke-when-i-deleted-my-config-file\" rel=\"noopener\">what broke when I deleted my config file<\/a>.<\/p>\n<h2 id=\"the-vue-and-svelte-trap\">The Vue and Svelte trap<\/h2>\n<p>One more thing that cost me time, and it&rsquo;s the sort of thing you only hit if you&rsquo;re in a component framework with scoped styles.<\/p>\n<p><code>@apply<\/code> inside a Vue or Svelte <code>&lt;style&gt;<\/code> block, or inside a CSS module, has no idea what your theme is. Different compilation context, no access to your variables or custom utilities. You get the unknown-utility error again, and this time it isn&rsquo;t your fault.<\/p>\n<p>The fix is <code>@reference<\/code>:<\/p>\n<pre><code class=\"language-html\">&lt;style&gt;\n  @reference &quot;..\/..\/app.css&quot;;\n\n  h1 {\n    @apply text-2xl font-bold text-red-500;\n  }\n&lt;\/style&gt;\n<\/code><\/pre>\n<p>That imports your stylesheet for reference without duplicating any of it in the output. If you haven&rsquo;t customised anything, <code>@reference \"tailwindcss\"<\/code> works too. I spent a while convinced this was a build config problem before I found the directive.<\/p>\n<h2 id=\"what-id-actually-do\">What I&rsquo;d actually do<\/h2>\n<p>If you&rsquo;re migrating and you have a <code>@layer components<\/code> block: don&rsquo;t convert it. Open it, and for each class, ask whether a component in your codebase already renders that exact element. Delete the ones where the answer is yes, convert the ones where the answer is no.<\/p>\n<p>That takes about an hour on a medium codebase and it&rsquo;s the highest-leverage hour in the whole migration, because every class you delete is a class you never have to keep in sync again.<\/p>\n<p>My CSS file is a quarter of the size it was and I have not once missed the deleted classes. The <code>@utility<\/code> error that started all this was annoying, but it pointed at something that had been wrong for years, and I&rsquo;d probably still be ignoring it otherwise.<\/p>\n<p>If you want to see how these patterns hold up on real projects rather than a blog example, there&rsquo;s more of that on my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">work page<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Tailwind v4 stopped letting @apply see my @layer components classes. The fix was not converting them to @utility, it was deleting most of them. Here is why.<\/p>\n","protected":false},"author":2,"featured_media":578,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Tailwind v4 stopped letting @apply see my @layer components classes. The fix was not converting them to @utility, it was deleting most of them. Here is why.","rank_math_focus_keyword":"tailwind utility directive","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[137],"tags":[37,38,139,134],"class_list":["post-579","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-css","tag-css","tag-frontend","tag-tailwind","tag-tailwind-v4"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/579","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=579"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/579\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/578"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=579"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=579"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=579"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}