Utilities and Plugins
Tailwind CSS Intermediate/Advanced
Chapter 3 · Custom Utilities, Variants and Plugins
Sooner or later you want a class Tailwind does not have: content-auto, a stack-3 that follows your spacing scale, a hocus: variant for hover-or-keyboard-focus, a
prose class for rendered Markdown. Tailwind v4 gives you five ways to add one, and picking the wrong one is the usual mistake. This chapter builds each one, shows exactly what it generates, and ends
with a rule for choosing. It assumes Tailwind CSS Fundamentals 10 (arbitrary values, @layer and @apply) and Tailwind CSS Intermediate/Advanced 1 (how the layers work).
@tailwindcss/typography 0.5.20 and @tailwindcss/forms 0.5.11, and headless Chrome 154 for computed styles. Not tested: other Tailwind
versions, plugins published by other authors, using a plugin through the Vite or PostCSS integrations (chapter 5), and any browser but Chrome.
Which Mechanism Do I Need?
Start from what you are trying to add, not from the feature name:
| I want to… | Use | Why |
|---|---|---|
| Style one element once | a normal class, or an arbitrary value (w-[13px]) | no new name needed |
Add a fixed-purpose class (content-auto) | @utility name { … } (static) | works with every variant, lives in the utilities layer |
Add a class that takes a value (tab-4, stack-3) | @utility name-* { … --value() … } (functional) | values come from your theme or the class itself, and arbitrary […] values work |
Add a new condition (hocus:, theme-midnight:) | @custom-variant | combines with every utility and every other variant |
| Use Tailwind's variants inside my own CSS rule | @variant | hover, md and dark without writing the media queries |
| Use a packaged set of styles (typography, forms) | @plugin | one line, with options |
| Read options, generate many rules, share through npm | a JavaScript plugin (plugin() or plugin.withOptions()) | the full API |
Keep an old project's tailwind.config.js working | @config | a bridge, not a destination |
| Name a reusable chunk of UI | a component in your framework (Tailwind CSS Fundamentals 12) | not a Tailwind feature at all |
Static Utilities: @utility
The simplest case is a name and some declarations. It lands in the utilities layer, so every variant works on it for free. I wrote two and used them with hover: and lg::
Two things to know. First, nesting (&::-webkit-scrollbar) is how one utility writes more than one rule. Second, a name that already exists is merged, not replaced. I defined
@utility underline { text-decoration-line: line-through; } and the output was a single .underline rule containing both declarations, underline first and
line-through second, so mine won. That is occasionally useful and usually an accident. A custom utility can also be used in your own CSS: .card { @apply glow-brand p-4; } compiled
to a plain rule with both utilities' declarations.
Functional Utilities: --value() and --modifier()
End a name with -* and the utility takes a value. The --value() function says what values are legal, and a class whose value does not match is simply not generated:
| Form | Example | Matches |
|---|---|---|
| Theme key | --value(--tab-size-*) | names defined in @theme (--tab-size-github: 8 gives tab-github) |
| Bare type | --value(integer) | tab-4. Types: number, integer, ratio, percentage |
| Arbitrary | --value([integer]) | tab-[12]; other types: length, color, url, *… |
| Literal | --value("inherit", "initial") | the exact words |
| Spacing scale | --spacing(--value(integer)) | turns 3 into calc(var(--spacing) * 3) |
| Modifier | --modifier(integer) | the part after a slash: pad-x-4/7; left out when absent |
| Default | --value(integer, --default(4)) | makes the bare class (tab) work |
Notice that several --value() declarations in one rule are tried in turn and the ones that fail are dropped; that is how stack-3 and stack-[10px] both work from one utility.
Notice also that the negative version, -nudge-2, needed its own @utility -nudge-*: a negative is never derived for you.
glow-* was box-shadow: 0 0 0 --value([length], 3px) …, trying to give the width a default. A literal such as 3px is not allowed in --value()
(bare values must be a number, integer, ratio or percentage). Tailwind printed Unsupported bare value data type: "3px" with a pointer to the exact spot, and then exited with code 0 and wrote the CSS file
anyway, with the one utility missing and every other utility present. I measured the exit code without a pipe: 0. A CI step that checks only the exit code would pass. Search the output for the error text, or assert
that a known class exists in the result.
Custom Variants: @custom-variant
A variant is a condition wrapped around a utility. The short form takes a selector in brackets; the block form wraps @slot (where the utility's declarations go) in whatever you need:
What the output shows:
:where()keeps specificity at zero. Thetheme-midnightselector is wrapped in it, so the variant never makes a utility harder to override than the same utility without the variant.- Conditions nest.
desktop-hoverneeds a wide screen and a hover-capable device, and two nested@mediablocks say exactly that. - You can build a variant out of existing ones.
dark-hoveruses@variant darkand@variant hoverinside@custom-variant, and the output contains both conditions. (The course outline called this "compound @variant, v4.3". The documentation page does not use that term, so I tested the behaviour rather than the name; it works in 4.3.3 and I did not check which release first allowed it.) - Everything combines.
md:theme-midnight:p-2andtheme-midnight:hover:text-whitework, andgroup-hocus:appeared with no extra work.
Tailwind's Variants in Your Own CSS: @variant
Use @variant inside a rule you write yourself (for example a legacy class) to get Tailwind's variants without repeating the media queries:
Stacking (hover:focus) nests the conditions and a comma list repeats the declarations for each variant. One caution from chapter 1: this .btn rule is outside every layer
(the output shows it after the @layer blocks), so it beats utilities such as p-8 on the same element. Wrap it in @layer components if you want utilities to win.
Official Plugins: Typography and Forms
A plugin is added with one line. Both official plugins declare Tailwind 4 in their peerDependencies and both worked in my build. Each adds rules, so I measured the cost and the effect. The baseline
stylesheet (one page, no plugin) was 4,614 bytes:
Points from those numbers. Typography turned unstyled HTML (an h2, a paragraph, a link, a list) into styled content through one class; the link became underlined with weight 500. After I
renamed the class to wysiwyg, the markup still said prose prose-lg and neither did anything: the heading was the base 24px, not the 30px of prose-lg. The class
option replaces the name; it does not add an alias. Forms by default restyles every bare input in the project, which is what you want for a quick reset and not what you want if other CSS styles those
inputs; strategy: class leaves plain inputs alone and only styles those with the class. The sizes depend on which of the plugin's classes your pages use, so treat them as one measurement, not a constant.
Writing a Plugin
The JavaScript plugin API is still the way to ship something other people install, or to generate many rules from data. A plugin file exports a function; plugin.withOptions() lets the CSS pass it
options. Here is a small one that uses most of the helpers:
The markup and what came out:
Three lessons from running it, each of which cost me a wrong first version:
- Name collisions are silent. My first plugin utility was called
text-shadow-md. Tailwind already hastext-shadow-*utilities, and the output was one rule with both declarations, the built-in's three-layer shadow and then mine, so mine won only because it came last. I renamed itglow-text-*. Check a name against the built-in list before you ship it. theme()returns the literal value, not avar().accent-ring-brandcompiled tooklch(0.55 0.2 260), whereas the@utility glow-*from earlier compiled tovar(--color-brand). A plugin utility therefore does not follow a variable that you re-define per theme (chapter 2). Use@utilitywith--value()when theming matters.addComponentsdid not use the components layer in this version:.pillwas emitted with the utilities. It was still overridden byp-8andpx-8(padding read from the browser: 12px/4px alone, 32px left withpx-8, 32px all round withp-8), because it was written before them, but do not depend on a layer guarantee. For a real component class, write@layer components { … }in CSS.
Rule of thumb: reach for @utility first. Move to a JavaScript plugin only when you need options, loops over data, or distribution through npm.
Legacy Configs: @config
A project moving from v3 can keep its tailwind.config.js with @config "./tailwind.config.js";. I wrote a config that used six common v3 options and checked which still took effect:
| Option in the JS config | Result | Evidence |
|---|---|---|
theme.extend.colors | works | bg-brand generated |
theme.extend.spacing | works | p-18 generated |
plugins: [function …] | works | a plugin utility generated |
darkMode: 'class' | works | dark:bg-black became .dark\:bg-black:is(.dark *) |
content: [...] | works (I did not expect this) | with source(none) and no @source, the classes in the listed file were still found; remove the line and nothing was scanned |
safelist: [...] | ignored | bg-red-500 in the list was not generated. Use @source inline() (chapter 1) |
corePlugins: { float: false } | ignored | float-left was still generated |
So a bridge config will keep most of a v3 theme working, but safelist and corePlugins fail silently, the same way as every other mistake in this course: no error, no rule. Chapter 8
walks through moving a v3 project fully into CSS.
--value() that did not fail the build. Covered above. How to avoid it: after every build that matters, check for the thing you expect, not just the exit code.2. A plugin utility named like a built-in.
text-shadow-md already exists. How to avoid it: look at the generated rule, not just whether it "worked"; two declarations in one rule is the tell.3. I repeated a name from my own outline. The outline said "compound @variant (v4.3)". The official page does not use that term and I could not date the feature, so the text above describes what I tested. How to avoid it: treat a planning note as a hypothesis until a run confirms it.
4. A command chain stopped quietly. I chained steps with
&& after a helper whose last command was grep; grep exits 1 when it finds nothing, so the rest was skipped and my output looked empty. How to avoid it: use ; when an earlier step may legitimately "fail", and read empty output as a question, not a result.
Hands-On Exercises
Write functional utilities tab-*, stack-*, glow-* and pad-x-* (with a modifier), plus a negative -nudge-*. Use a class list that mixes valid and invalid values and list exactly which classes Tailwind generates. Then break one utility on purpose with a literal inside --value() and report the exit code and what was still generated.
Define theme-midnight, hocus, any-hover, desktop-hover and a dark-hover built from @variant dark and @variant hover. Use stacked forms such as md:theme-midnight:p-2 and group-hocus:, list the generated selectors, and then use @variant (with stacking and a comma list) inside a .btn rule of your own.
Write a JavaScript plugin with options that adds a base rule, a static utility, a value-taking utility, a colour utility fed from theme('colors'), a variant and a matchVariant. Pass an option from the CSS, list what it generated, and read the computed padding of a plugin-defined class combined with p-8 in Chrome.
Chapter 3 Quick Reference
- Choose: arbitrary value (one-off) →
@utility(reusable class) →@custom-variant(new condition) →@variant(variants in your own CSS) →@plugin(packaged) → JS plugin (options, data, npm) →@config(bridge for old projects) @utility name { … }is static and works with all variants; a name that exists is merged with the built-in, yours last@utility name-* { … --value(…) … }: forms are theme key--value(--x-*), bare typeinteger, arbitrary[length], literal"x";--spacing(),--modifier(),--default(); negatives need their own-name-*- A class whose value does not match is not generated. A bad
--value()prints an error but the build exits 0 @custom-variant name (&:selector);or a block with@slot; wrap in:where()for zero specificity; nest at-rules for two conditions; compose built-ins with@variant@variant hover { … }inside your CSS;hover:focusstacks,dark, printrepeats. Your own unlayered rules still beat utilities@plugin "@tailwindcss/typography"(prose,classNamerenames, not aliases) and@plugin "@tailwindcss/forms"(strategy: classto avoid restyling bare inputs)- JS plugin:
plugin.withOptions;addBase,addUtilities,matchUtilities,addVariant,matchVariant;theme()gives literal values, notvar();addComponentslanded in the utilities layer; avoid built-in names @config:theme.extend,plugins,darkModeandcontentwork;safelistandcorePluginsare ignored
group/card, peer/field), has-*, not-*, in-* and nth-*, the
*: and **: child variants, data-* state machines, starting: and logical variants. Start with Tailwind CSS Intermediate/Advanced 4.