05 · Reusing Styles: Components, @apply & @utility¶
The first complaint about Tailwind is always the same: "my button has fifteen classes
and I use it in forty places". The answer is rarely "move the classes back into CSS". It's
usually "make the button a component". Tailwind does have CSS-side tools for reuse
(@apply, @layer components, @utility), and they're the right choice in some
situations. This lesson goes through the options in the order you should consider them,
and shows exactly what each one compiles to.
Option 1: don't repeat it in the first place¶
Before reaching for any directive, check whether the repetition is real.
-
Loops. Eight nav links rendered by a loop are written once:
-
Multi-cursor editing. If the repetition is within one file (a table's cells, say), your editor can change all copies at once.
-
Components and partials. This is the main answer. A button that appears in forty places should be one React/Vue/Svelte component, one Blade/Jinja/ERB partial, or one template macro:
Button.jsxexport function Button({ children, ...props }) { return ( <button className="rounded-md bg-sky-700 px-4 py-2 font-medium text-white hover:bg-sky-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 disabled:opacity-50" {...props} > {children} </button> ); }
A component reuses the markup as well as the classes: the <button> element, its
attributes, its icon slot. That's something no CSS technique can do. Level 3 · 06 covers
variants (primary/secondary, sizes) and merging classes passed in from outside.
Option 2: @apply inside your own CSS¶
@apply copies the declarations of utilities into a rule you write. It's useful when you
need a real CSS class: markup you can't turn into components (Markdown output, a CMS
template, a third-party widget), or a codebase without a component system.
@import "tailwindcss";
@layer components {
.btn {
@apply rounded-md px-4 py-2 font-medium hover:bg-sky-700
focus-visible:outline-2 md:px-6;
}
}
The compiled output (trimmed) shows variants turning into nested rules:
@layer components {
.btn {
border-radius: var(--radius-md);
padding-inline: calc(var(--spacing) * 4);
padding-block: calc(var(--spacing) * 2);
font-weight: var(--font-weight-medium);
&:hover {
@media (hover: hover) { background-color: var(--color-sky-700); }
}
&:focus-visible { outline-style: var(--tw-outline-style); outline-width: 2px; }
@media (width >= 48rem) { padding-inline: calc(var(--spacing) * 6); }
}
}
That's CSS nesting in the development output, which current browsers support natively.
Building with the CLI's --minify flag runs the output through Lightning CSS, and in our
test it flattened the nesting into ordinary rules: .btn:hover{…} inside
@media (hover:hover), and a separate .btn{…} inside @media (min-width:48rem).
Why @layer components¶
Tailwind's stylesheet declares layers in this order: theme, base, components,
utilities. Rules in a later layer win over rules in an earlier one regardless of
specificity. Putting .btn in components means a utility on the same element always
overrides it:
.btn's padding is in components, .px-2 is in utilities, so px-2 wins, even
against .btn's md: padding inside a media query. That's exactly the behaviour you
want: a base component class you can tweak with utilities.
If you write the class outside any layer, it beats every layered rule, including all
utilities. We gave two links px-2 (8px) plus a class setting padding-inline: 2rem,
one class inside @layer components and one unlayered. The layered one computed to
8px (the utility won) and the unlayered one to 32px (the utility lost).
Unlayered CSS always wins over layered CSS, so keep your own component classes in
@layer components.
Option 3: @utility for things that behave like utilities¶
@utility registers a new utility with Tailwind itself. Unlike a plain class, it
works with every variant, is sorted with the other utilities, and is only emitted when
used:
@utility content-auto {
content-visibility: auto;
}
@utility scrollbar-hidden {
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
<section class="md:content-auto">…</section>
<div class="flex overflow-x-auto scrollbar-hidden">…</div>
md:content-auto compiled to @media (width >= 48rem) { .md\:content-auto
{ content-visibility: auto; } }. A plain CSS class can't do that.
Use @utility for small, single-purpose helpers, especially CSS properties Tailwind
doesn't cover. You can use @apply inside one, but a multi-property "component" defined
as a utility has a catch: it lives in the utilities layer and has to compete with the
utilities you add next to it. In our test, class="btn-primary px-2" (where
btn-primary was an @utility with five declarations, including padding) happened to
let px-2 win, because Tailwind's sort placed .btn-primary before .px-2. That ordering is an implementation detail you
shouldn't build on. Big component styles belong in @layer components or, better, a
real component. Level 3 · 03 covers functional utilities that take values
(tab-4, tab-8).
Option 4: @reference for component-scoped CSS¶
Vue and Svelte <style> blocks, CSS Modules and Astro styles are compiled separately
from your main stylesheet. If you use @apply in them, Tailwind doesn't know your theme.
We compiled a file containing only .title { @apply text-brand font-bold; } and got:
Error: Cannot apply unknown utility class `text-brand`. Are you using CSS modules
or similar and missing `@reference`?
@reference imports your main CSS for reference only: its theme, custom utilities
and variants become available, but none of its CSS is output again:
With the reference added, the output was just the .title rule (plus a couple of
@property registrations), with no second copy of Preflight or the theme. If you don't
have custom theme values, @reference "tailwindcss"; is enough.
A simpler alternative in these files is to skip @apply and use the theme variables
directly: .title { color: var(--color-brand); }.
Worked example: Markdown content you can't add classes to¶
A blog renders Markdown to HTML, so you can't put classes on each <a> and <code>.
This is a legitimate case for a CSS class:
@import "tailwindcss";
@layer components {
.post-body {
@apply text-gray-800 leading-7;
& a { @apply font-medium text-sky-700 underline underline-offset-2 hover:text-sky-900; }
& code { @apply rounded bg-gray-100 px-1 py-0.5 font-mono text-sm; }
& h2 { @apply mt-10 mb-3 text-2xl font-semibold tracking-tight; }
& p + p { @apply mt-4; }
}
}
The template uses one class; the Markdown elements are styled through nesting. For a full long-form content style, the Typography plugin (Level 3 · 05) does this job with a lot more polish.
How It Actually Works¶
@apply runs at compile time. For each class you list, Tailwind generates the utility as
usual, then takes its declarations and pastes them into your rule. If the utility has
variants, the variant's selector part becomes a nested rule (&:hover) and any at-rule
(@media) wraps those declarations, which is why the output uses CSS nesting. Nothing
links the two afterwards: the .btn rule doesn't depend on .px-4 existing in the
output.
@utility is a different mechanism. It adds an entry to the same registry the built-in
utilities live in. When the scanner finds md:content-auto, Tailwind looks up
content-auto, finds your definition, applies the md variant, and sorts the result
with everything else. That's why custom utilities support variants and why they're
tree-shaken like built-ins.
@reference parses the referenced file to build the theme, utilities and variants, then
discards its output. It's import-for-types, applied to CSS.
Common mistakes¶
- Using
@applyto recreate a component library in CSS when you have a component framework. You lose the markup reuse and add a second place to look for styles. - Writing component classes outside
@layer components. Unlayered CSS beats every utility, soclass="btn px-2"can't be adjusted. - Defining a big multi-property component with
@utilityand expecting utilities next to it to win predictably. @applyin Vue/Svelte/CSS Modules without@reference, which fails with "Cannot apply unknown utility class".@import "tailwindcss"in every component's style block instead of@reference. Each one outputs a full copy of Preflight and the theme.- Extracting too early. Wait until a pattern is repeated and stable before naming it.
Exercise¶
- Find the most repeated class list in your Level 1 landing page. Turn it into (a) a
template loop or partial and (b) a
.btnclass with@apply. Which was cleaner? - Prove the layer behaviour: put
.btnin@layer components, addpx-2to one button, and confirm it applies. Move.btnout of the layer and watch it stop working. - Create
@utility scrollbar-hiddenand use it with a responsive variant (md:scrollbar-hidden). - If you have a Vue or Svelte project, write a scoped style block that uses
@applywith a custom colour, see the error, and fix it with@reference.