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.
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:
| Class | Generated 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:
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:
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:
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:
| Class | Meaning | Result |
|---|---|---|
| [&>*]:p-2 | the element's direct children | a child's padding measured 8px |
| [&_p]:mt-2 | any p inside (underscore is a space) | a nested p measured 8px margin-top |
| lg:[&:nth-child(-n+3)]:hover:underline | on large screens, the first three siblings, on hover | compiled to :nth-child(-n+3):hover inside the media queries |
| supports-[display:grid]:grid | @supports (display:grid) | display measured grid |
| group-[.open]:block | inside a .group that also has the class open | hidden group-[.open]:block: none without the class, block with it |
| @[500px]:p-4 | a container query at an exact width | compiled to @container (width >= 500px) |
| [@media(width>=500px)]:p-1 | any at-rule | compiled 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):
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:
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:
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:
.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:
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 with | Result | Output 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 time | 5,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:
--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
| Situation | Use |
|---|---|
| A one-off value that is not on the scale | an arbitrary value: w-[372px] |
| A value that changes at run time | a CSS variable: w-(--w) with style="--w: …" |
| A property or selector with no utility, used once | an 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 templates | a 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 CSS | plain 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
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.
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.
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.
Chapter 10 Quick Reference
- Arbitrary value
w-[372px],_= space (grid-cols-[1fr_auto]),calc()gets its spaces; variable shorthandw-(--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-variantdefines 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) @applyinlines 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), plainvar(--color-blue-500);theme()is deprecated - Ladder: arbitrary value, arbitrary property/variant,
@utility, component in your framework,@layer components, plain CSS
transition-* and duration-*, transforms (scale, rotate, translate and 3D),
the built-in animate-* classes, and starting: for enter animations.