Beyond Utilities

Tailwind CSS Fundamentals

Chapter 10 · Going Beyond the Utilities

Sooner or later you need something Tailwind does not ship: a 372px width, a selector that targets the children, a utility your team uses everywhere, a component class for markup you cannot edit. Tailwind gives you a ladder of escape hatches, and the skill is to take the lightest one that works: an arbitrary value, then an arbitrary property or variant, then your own @utility or @variant, then a class in @layer components (with @apply), and only then a plain stylesheet. This chapter climbs the ladder, and it explains the cascade-layer rule that decides whether your own CSS beats Tailwind's or loses to it.

Every example here was run
Every class and directive was compiled with Tailwind CSS 4.3.3 and the output read; behaviour (widths, computed values, which rule wins, what happens when hovering or when the system is in dark mode) was checked in headless Chrome. The official documentation was used for the directive syntax; where I say a behaviour is not in the documentation, I tested it myself.

Arbitrary Values

Put a value in square brackets and Tailwind uses it as written. The class name is the utility plus the value; you saw this in chapters 2 and 3 with p-[13px] and bg-[#bada55]. From the compiled output:

ClassGenerated CSS
w-[372px]width: 372px
top-[117px]top: 117px
bg-[#bada55]background-color: #bada55
grid-cols-[1fr_auto]grid-template-columns: 1fr auto (an underscore is a space)
content-['a_b']content: 'a b' (the underscore again; before:content-['Festivus'] adds text)
p-[calc(1rem+2px)]padding: calc(1rem + 2px) (Tailwind adds the spaces CSS needs around +)
aspect-[4/3]aspect-ratio: 4/3
bg-[url('/x.png')]background-image: url('/x.png')

I measured a w-[calc(100%-2rem)] box inside a 600px parent: 568px (600 − 32). The writing rules are small: no spaces inside the brackets (use _ where you need one), and the whole class name must still appear in your files as one piece, which is the chapter 1 rule again.

CSS Variables as Values

To use a CSS variable, write it in parentheses: w-(--w) is shorthand for w-[var(--w)] and adds the var() for you. This is the right answer to a value that changes at run time, because a class name cannot be built from a variable (chapter 1) but a variable can be set from anywhere, including an inline style:

<div class="w-(--w) h-4" style="--w: 200px"></div>
.w-\(--w\) { width: var(--w); } measured width: 200px

The one trap is type ambiguity. Several utilities share a prefix: text- sets font size or colour, bg- sets colour, image or size. Given only a variable, Tailwind cannot tell which you mean, so it picks colour, and I compiled the proof:

text-(--v) text-[var(--v2)] text-(length:--sz) text-(color:--clr) bg-(--bgv) bg-(image:--img) bg-(length:--sz)
text-(--v) color: var(--v) text-[var(--v2)] color: var(--v2) text-(length:--sz) font-size: var(--sz) (measured with --sz: 30px: 30px) text-(color:--clr) color: var(--clr) (measured with --clr: rgb(255, 0, 0): red) bg-(--bgv) background-color: var(--bgv) bg-(image:--img) background-image: var(--img) bg-(length:--sz) background-size: var(--sz)

So a variable holding a size needs the hint, text-(length:--sz); the official docs show the same two hints for text-.

Arbitrary Properties and Variants

If there is no utility at all for a CSS property, write the property itself in brackets: [mask-type:luminance] compiled to mask-type: luminance and the element's computed value was luminance. Variants work in front, so hover:[mask-type:alpha] is a hover rule. A good use is setting a variable that other classes read, differently at different sizes:

<div class="[--scroll-offset:56px] lg:[--scroll-offset:44px]"></div>
viewport 1000px: --scroll-offset = 56px viewport 1100px: --scroll-offset = 44px (lg: starts at 1024px)

Arbitrary variants do the same for selectors and conditions: a bracketed variant is a selector in which & stands for the element itself. All of these were measured or compiled:

ClassMeaningResult
[&>*]:p-2the element's direct childrena child's padding measured 8px
[&_p]:mt-2any p inside (underscore is a space)a nested p measured 8px margin-top
lg:[&:nth-child(-n+3)]:hover:underlineon large screens, the first three siblings, on hovercompiled to :nth-child(-n+3):hover inside the media queries
supports-[display:grid]:grid@supports (display:grid)display measured grid
group-[.open]:blockinside a .group that also has the class openhidden group-[.open]:block: none without the class, block with it
@[500px]:p-4a container query at an exact widthcompiled to @container (width >= 500px)
[@media(width>=500px)]:p-1any at-rulecompiled to @media (width>=500px)

The official docs describe arbitrary properties as "really like inline styles, but again with the benefit that you can use modifiers". They are for the rare one-off. If you find yourself writing the same bracketed class in many places, it deserves a name, which is the next rung.

Your Own Utilities and Variants

@utility defines a class that behaves like a built-in one, including working with every variant. A static one is a name and some declarations (nested selectors are allowed):

@utility scrollbar-hidden { scrollbar-width: none; &::-webkit-scrollbar { display: none; } }
.scrollbar-hidden { scrollbar-width: none; &::-webkit-scrollbar { display: none; } } @media (hover: hover) { .hover\:scrollbar-hidden:hover { scrollbar-width: none; } .hover\:scrollbar-hidden:hover::-webkit-scrollbar { display: none; } } measured scrollbar-width: scrollbar-hidden none hover:scrollbar-hidden auto at rest, none while hovered

A functional utility takes a value from the class name: the * in the name stands for it, and --value() says what values are allowed. This one accepts a theme variable, a bare integer, or a bracketed integer:

@theme { --tab-size-github: 8; } @utility tab-* { tab-size: --value(--tab-size-*, integer, [integer]); }
tab-4 tab-size: 4 measured 4 tab-github tab-size: var(--tab-size-github) measured 8 tab-[3] tab-size: 3 measured 3 md:tab-2 tab-size: 2 from 768px measured 4 at 700px, 2 at 800px

The documentation shows each form of --value() on its own; I combined them in one list and it compiled and behaved as above. It also shows variants on static utilities only, so I checked a functional one: md:tab-2 worked. For variants, @custom-variant (chapter 8) defines a new condition. Its companion @variant goes the other way: it lets your own CSS use a Tailwind variant. This rule compiled to the media queries shown in the comments, and I measured it:

.my-element { background: white; @variant dark { background: black; } /* @media (prefers-color-scheme: dark) */ @variant hover { background: red; } /* &:hover inside @media (hover: hover) */ }
measured background: system light rgb(255, 255, 255) system dark rgb(0, 0, 0) pointer over it rgb(255, 0, 0)

Cascade Layers: Who Wins, Your CSS or Tailwind's

Tailwind puts its output into cascade layers (chapter 1): theme, base, components, utilities, in that order of strength. You can add your own CSS to base (element defaults, such as sizes for h1 and h2) or components (classes like .card), and anything in utilities you define with @utility lands in the utilities layer. The reason to use @layer components is exactly that its place in the order is below the utilities, so you can still adjust a component with a class. I measured three cases:

@layer components { .card { padding: 1rem; border-radius: 0.5rem; } } .btn-unlayered { padding: 4px; } /* not in any layer */ <div class="card p-8">…</div> <div class="card rounded-none">…</div> <div class="btn-unlayered p-8">…</div>
.card p-8 padding-top 32px the utility beat the component class .card rounded-none radius 0px the utility beat the component class .btn-unlayered p-8 padding-top 4px the UNLAYERED rule beat the utility
CSS you write outside @layer beats every utility
The third result is the surprise. In CSS, a style that is not in any layer outranks styles in layers, whatever the selectors' specificity. Tailwind's utilities are all in a layer, so a plain .btn { padding: 4px } wins against p-8, and utilities mysteriously "stop working" on that element. Moving the same rule into @layer components { .btn { padding: 4px } } measured: btn p-8 became 32px, and btn alone stayed 4px. The practical rule: put your own CSS in @layer base or @layer components unless you deliberately want it to override Tailwind.

@apply and @reference

@apply pastes the declarations of existing utilities into your own rule, which lets you name a combination once and use it on markup you cannot give lots of classes to:

@layer components { .btn-primary { @apply rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700; } }
.btn-primary { border-radius: var(--radius-md); background-color: var(--color-blue-600); padding-inline: calc(var(--spacing) * 4); padding-block: calc(var(--spacing) * 2); color: var(--color-white); &:hover { @media (hover: hover) { background-color: var(--color-blue-700); } } } measured: background blue-600; blue-700 while hovered

Variants work inside @apply, and an ! at the end of a utility name produced !important (@apply bg-red-500! compiled to background-color: var(--color-red-500) !important; this form is not in the documentation, I tested it). The docs' own example is the right use: styling markup produced by something else (.select2-dropdown { @apply rounded-b-lg shadow-md; }).

Use @apply sparingly. It copies the declarations every time you use it, it moves your styling to a second file where you have to hunt for it, and it brings back the naming problem that utility classes were meant to remove. If the repetition is in your own templates, make a component (a partial, a function, a Vue or React component) so the classes are written once. Reserve @apply for markup you do not control.

@reference is for CSS that lives in a different file from your Tailwind import: a Vue or Svelte <style> block or a CSS module. That file does not know Tailwind's theme, so @apply fails. I compiled the same rule three ways:

File starts withResultOutput size
(nothing)Error: Cannot apply unknown utility class `text-2xl`. Are you using CSS modules or similar and missing `@reference`?none: it fails
@reference "tailwindcss";works: only the .title rule, with the theme values as fallbacks (var(--text-2xl, 1.5rem))672 bytes
@import "tailwindcss";works, but includes Tailwind's whole theme and base layers a second time5,305 bytes

So in a component file write @reference "../app.css"; (the path to your main stylesheet, so your custom theme, utilities and variants are known) and never @import "tailwindcss" again.

CSS Functions and Variables in Your Own CSS

In ordinary CSS you can use the theme directly. Tailwind adds two functions, and the older theme() still works:

.fn-demo { color: --alpha(var(--color-lime-300) / 50%); margin: --spacing(4); background: var(--color-blue-500); border-radius: theme(borderRadius.lg); }
--alpha(…) color-mix(in oklab, var(--color-lime-300) 50%, transparent) (with a fallback for older browsers) --spacing(4) calc(var(--spacing) * 4) measured margin: 16px var(--color-blue-500) a plain theme variable theme(borderRadius.lg) 0.5rem measured radius: 8px

--alpha() is the CSS equivalent of the /50 opacity modifier, and --spacing(4) of p-4. theme() is the v3 way: it compiled in 4.3.3, but the documentation lists it as deprecated and recommends theme variables, so prefer var(--radius-lg).

When to Write Plain CSS

SituationUse
A one-off value that is not on the scalean arbitrary value: w-[372px]
A value that changes at run timea CSS variable: w-(--w) with style="--w: …"
A property or selector with no utility, used oncean arbitrary property or variant: [mask-type:alpha], [&>*]:p-2
A low-level style used across the site that should work with variants@utility
The same group of classes repeated in your own templatesa component in your framework, not CSS
Markup you cannot edit (a library's widgets, rendered Markdown)@layer components with @apply or plain CSS
Long keyframe sequences, complex selectors, print layouts, anything that reads better as CSSplain CSS in a layer, using theme variables

Mixing is normal and healthy: Tailwind does not ask you to give up CSS, only to reach for it last. The next chapters stay in the utility world; Tailwind CSS Intermediate/Advanced 3 returns to custom utilities, variants and plugins in more depth.

Hands-On Exercises

Exercise 1

Use four off-scale techniques on one page and prove each: a width that is "the parent minus 2rem", a width taken from a CSS variable set in an inline style, a font size taken from a variable, and a variable-driven colour. Then show what Tailwind generates for an unhinted text-(--v) and explain why a size variable needs a hint.

📄 View solution
Exercise 2

Write a static scrollbar-hidden utility and a functional tab-* utility that accepts a theme name (tab-github = 8), a number (tab-4) and a bracketed number (tab-[3]). Prove that both work with the hover: and md: variants, and then write a plain CSS rule that uses @variant for dark mode and hover and show the three resulting colours.

📄 View solution
Exercise 3

A teammate wrote .btn { padding: 4px } in the stylesheet and now p-8 on a button does nothing. Reproduce and measure the problem, fix it without changing the markup, and measure again. Then write a separate component CSS file that uses @apply, show the exact error you get without @reference, and compare the output sizes of the two fixes.

📄 View solution

Chapter 10 Quick Reference

  • Arbitrary value w-[372px], _ = space (grid-cols-[1fr_auto]), calc() gets its spaces; variable shorthand w-(--w); the class must still appear whole in your files
  • Ambiguous variables default to colour: use hints text-(length:--sz), bg-(image:--img), bg-(length:--sz), text-(color:--clr)
  • Arbitrary property [mask-type:luminance]; set variables per breakpoint [--x:56px] lg:[--x:44px]; arbitrary variants [&>*]:p-2, [&_p]:mt-2, supports-[…]:, group-[.open]:, @[500px]:, [@media(…)]:
  • @utility name { … } (works with all variants); functional @utility tab-* { tab-size: --value(--tab-size-*, integer, [integer]); }; @variant dark { … } inside your own CSS; @custom-variant defines new variants
  • Unlayered CSS beats all utilities. Put your CSS in @layer base / @layer components; then utilities can still override it (card p-8 = 32px)
  • @apply inlines utilities (variants and ! work); use it for markup you cannot edit, not to avoid writing classes; in other files @reference "../app.css" (672 bytes vs 5,305 for a re-import)
  • CSS functions: --alpha(var(--color-x) / 50%), --spacing(4), plain var(--color-blue-500); theme() is deprecated
  • Ladder: arbitrary value, arbitrary property/variant, @utility, component in your framework, @layer components, plain CSS
Coming next
Tailwind CSS Fundamentals 11 covers motion: transition-* and duration-*, transforms (scale, rotate, translate and 3D), the built-in animate-* classes, and starting: for enter animations.