Skip to content
CSS

The @apply Habit I Broke Moving to Tailwind v4

The @apply Habit I Broke Moving to Tailwind v4

I spent about two hours last month trying to work out why @apply btn-primary was throwing “Cannot apply unknown utility class” when btn-primary was very obviously defined about nine lines above it in the same file.

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’t know about classes you define in @layer components. If you want @apply to see a class, it has to be registered as a utility, which means @utility. That’s it. That’s the whole bug.

But the fix I ended up shipping wasn’t “swap @layer components for @utility.” It was deleting most of those classes entirely, which is a thing I’d been putting off for roughly three years.

The habit I’d built and why v4 broke it

Here’s the pattern I’d been writing since about 2021:

/* Tailwind v3 */
@layer components {
  .btn {
    @apply inline-flex items-center rounded-md px-4 py-2 font-medium;
  }
  .btn-primary {
    @apply btn bg-indigo-600 text-white hover:bg-indigo-700;
  }
  .btn-ghost {
    @apply btn bg-transparent text-indigo-600 hover:bg-indigo-50;
  }
}

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.

In v4, .btn-primary composing .btn via @apply doesn’t work, because .btn isn’t a utility as far as the engine is concerned. You can fix it directly:

/* Tailwind v4 */
@utility btn {
  display: inline-flex;
  align-items: center;
  border-radius: var(--radius-md);
  padding-inline: --spacing(4);
  padding-block: --spacing(2);
  font-weight: 500;
}

Now btn is a real utility, works with variants like hover: and lg:, and @apply btn resolves. The functions and directives reference has the full list of what @utility accepts.

That’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.

Why I deleted the rest instead

Once I’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 <Button> component. I also had a .btn-primary class. Two places encoding the same design decision, and every time a designer changed the padding I had to remember both.

So the variant logic moved into the component, where it was already half living:

const styles = {
  base: 'inline-flex items-center rounded-md px-4 py-2 font-medium',
  primary: 'bg-indigo-600 text-white hover:bg-indigo-700',
  ghost: 'bg-transparent text-indigo-600 hover:bg-indigo-50',
}

export function Button({ variant = 'primary', className, ...props }) {
  return (
    <button
      className={cn(styles.base, styles[variant], className)}
      {...props}
    />
  )
}

Nothing clever. A lookup object and a class merger. If you want the typed version with compound variants, class-variance-authority does this properly and I use it on anything with more than three variants, but for a button this is enough.

The thing I like: there’s now exactly one place that knows what a primary button looks like, and it’s the file named Button.jsx. The CSS file went from 340 lines to about 40.

When @utility is still the right answer

I’m not arguing you should never write custom CSS. Three cases where I still reach for @utility:

Genuinely reusable primitives that aren’t components. tab-4, a text-balance shim, a scrollbar-gutter helper. Things that are one property and belong in the utility vocabulary.

Styling markup you don’t control. Third party widgets, CMS output, anything where you can’t add classes to the element. @apply inside a plain CSS selector is exactly right here, and the docs use a Select2 dropdown as the example for good reason.

Design tokens, which aren’t @utility at all but @theme. This is the part of v4 I’d argue is the actual improvement:

@theme {
  --color-brand-500: oklch(0.62 0.19 264);
  --radius-card: 0.75rem;
}

Defining a theme variable doesn’t just create a CSS variable, it creates the corresponding utility classes. bg-brand-500 and rounded-card now exist. That’s a much better home for design decisions than a JavaScript config object, and it’s the reason my tailwind.config.js is gone. I wrote about the fallout from that in what broke when I deleted my config file.

The Vue and Svelte trap

One more thing that cost me time, and it’s the sort of thing you only hit if you’re in a component framework with scoped styles.

@apply inside a Vue or Svelte <style> 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’t your fault.

The fix is @reference:

<style>
  @reference "../../app.css";

  h1 {
    @apply text-2xl font-bold text-red-500;
  }
</style>

That imports your stylesheet for reference without duplicating any of it in the output. If you haven’t customised anything, @reference "tailwindcss" works too. I spent a while convinced this was a build config problem before I found the directive.

What I’d actually do

If you’re migrating and you have a @layer components block: don’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.

That takes about an hour on a medium codebase and it’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.

My CSS file is a quarter of the size it was and I have not once missed the deleted classes. The @utility error that started all this was annoying, but it pointed at something that had been wrong for years, and I’d probably still be ignoring it otherwise.

If you want to see how these patterns hold up on real projects rather than a blog example, there’s more of that on my work page.