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).

Every example here was run
Tailwind CLI 4.3.3 on Node 24.18.0, with @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…UseWhy
Style one element oncea 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-variantcombines with every utility and every other variant
Use Tailwind's variants inside my own CSS rule@varianthover, md and dark without writing the media queries
Use a packaged set of styles (typography, forms)@pluginone line, with options
Read options, generate many rules, share through npma JavaScript plugin (plugin() or plugin.withOptions())the full API
Keep an old project's tailwind.config.js working@configa bridge, not a destination
Name a reusable chunk of UIa 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::

@utility content-auto { content-visibility: auto; } @utility scrollbar-hidden { scrollbar-width: none; &::-webkit-scrollbar { display: none; } } <i class="content-auto hover:content-auto lg:scrollbar-hidden"></i>
.content-auto { content-visibility: auto; } @media (hover: hover) { .hover\:content-auto:hover { content-visibility: auto; } } @media (width >= 64rem) { .lg\:scrollbar-hidden { scrollbar-width: none; } .lg\:scrollbar-hidden::-webkit-scrollbar { display: none; } }

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:

FormExampleMatches
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
@theme { --tab-size-github: 8; --color-brand: oklch(0.55 0.2 260); } @utility tab-* { tab-size: --value(--tab-size-*); tab-size: --value(integer); tab-size: --value([integer]); } @utility stack-* { display: flex; flex-direction: column; gap: --spacing(--value(integer)); gap: --value([length]); } @utility glow-* { box-shadow: 0 0 0 3px --value(--color-*, [color]); } @utility pad-x-* { padding-inline: --spacing(--value(integer)); line-height: --modifier(integer); } @utility nudge-* { translate: --spacing(--value(integer)) 0; } @utility -nudge-* { translate: calc(--spacing(--value(integer)) * -1) 0; } <i class="tab-4 tab-github tab-[12] tab-x tab-4.5"></i> <i class="stack-3 stack-[10px] stack-[2rem] stack-x"></i> <i class="glow-brand glow-[#f00] glow-red-500 glow-nope"></i> <i class="pad-x-4 pad-x-4/7"></i> <i class="nudge-2 -nudge-2"></i>
generated: tab-4 tab-github tab-[12] stack-3 stack-[10px] stack-[2rem] glow-brand glow-[#f00] glow-red-500 pad-x-4 pad-x-4/7 (line-height: 7) nudge-2 -nudge-2 not written: tab-x tab-4.5 stack-x glow-nope (no error, no rule) .stack-3 { display: flex; flex-direction: column; gap: calc(var(--spacing) * 3); } .glow-brand { box-shadow: 0 0 0 3px var(--color-brand); } (reads the theme variable, so it follows a re-themed --color-brand)

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.

A mistake that does not fail the build
My first 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:

@import "tailwindcss" source(none); @custom-variant theme-midnight (&:where([data-theme="midnight"], [data-theme="midnight"] *)); @custom-variant hocus (&:is(:hover, :focus-visible)); @custom-variant any-hover { @media (any-hover: hover) { &:hover { @slot; } } } @custom-variant desktop-hover { @media (width >= 64rem) { @media (hover: hover) { &:hover { @slot; } } } } @custom-variant dark-hover { @variant dark { @variant hover { @slot; } } }
theme-midnight:bg-black .…:where([data-theme="midnight"], [data-theme="midnight"] *) { background-color: var(--color-black); } hocus:underline .…:is(:hover, :focus-visible) { text-decoration-line: underline; } any-hover:font-bold @media (any-hover: hover) { .…:hover { font-weight: … } } desktop-hover:italic @media (width >= 64rem) { @media (hover: hover) { .…:hover { font-style: italic } } } dark-hover:uppercase @media (prefers-color-scheme: dark) { @media (hover: hover) { .…:hover { text-transform: uppercase } } } md:theme-midnight:p-2 inside @media (width >= 48rem), with the midnight selector theme-midnight:hover:text-white the midnight selector, plus the hover media query group-hocus:flex .…:is(:where(.group):is(:hover, :focus-visible) *) (never defined by me)

What the output shows:

  • :where() keeps specificity at zero. The theme-midnight selector is wrapped in it, so the variant never makes a utility harder to override than the same utility without the variant.
  • Conditions nest. desktop-hover needs a wide screen and a hover-capable device, and two nested @media blocks say exactly that.
  • You can build a variant out of existing ones. dark-hover uses @variant dark and @variant hover inside @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-2 and theme-midnight:hover:text-white work, and group-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:

.btn { background: white; @variant hover { background: black; } @variant md { padding: 1rem; } @variant hover:focus { outline: 2px solid red; } @variant dark, print { color: gray; } }
.btn { background: white; &:hover { @media (hover: hover) { background: black; } } @media (width >= 48rem) { padding: 1rem; } &:hover { @media (hover: hover) { &:focus { outline: 2px solid red; } } } @media (prefers-color-scheme: dark) { color: gray; } @media print { color: gray; } }

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:

@plugin "@tailwindcss/typography"; /* the classes prose, prose-lg, prose-invert, … */ @plugin "@tailwindcss/typography" { className: wysiwyg; } /* rename the class */ @plugin "@tailwindcss/forms"; /* styles bare inputs, selects, checkboxes */ @plugin "@tailwindcss/forms" { strategy: class; } /* only .form-input, .form-select, … */
size <h2> in .prose <p> in .prose bare <input type=text> no plugin 4,614 B 16px / 400 16px / 24px border 0, padding 0 typography (class prose prose-lg) 27,741 B 30px / 700 18px / 32px – typography, className: wysiwyg 20,677 B 24px / 700 16px / 28px – forms 12,962 B – – border 1px, padding 8px forms, strategy: class 6,804 B – – border 0 (unchanged); .form-input: 1px, 8px

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:

const plugin = require('tailwindcss/plugin'); module.exports = plugin.withOptions(function (options = {}) { const color = options.color || 'rgb(0 0 0 / 0.3)'; return function ({ addBase, addComponents, addUtilities, matchUtilities, addVariant, matchVariant, theme }) { addBase({ 'h1, h2': { 'text-wrap': 'balance' } }); addComponents({ '.pill': { 'border-radius': '9999px', 'padding': '0.25rem 0.75rem' } }); addUtilities({ '.no-scrollbar': { 'scrollbar-width': 'none' } }); matchUtilities( { 'glow-text': (value) => ({ 'text-shadow': `0 0 ${value} ${color}` }) }, { values: { sm: '2px', md: '6px', lg: '12px' } } ); matchUtilities( { 'accent-ring': (value) => ({ 'box-shadow': `0 0 0 3px ${value}` }) }, { values: theme('colors') , type: 'color' } ); addVariant('hocus', '&:is(:hover, :focus-visible)'); matchVariant('nth', (value) => `&:nth-child(${value})`, { values: { 1: '1', 2: '2', odd: 'odd' } }); }; }); /* in the CSS: */ @theme { --color-brand: oklch(0.55 0.2 260); } @plugin "./shadow.js" { color: rgb(255 0 0 / 0.5); }

The markup and what came out:

<h1 class="pill no-scrollbar glow-text-md glow-text-[9px] hocus:underline nth-odd:italic nth-2:font-bold accent-ring-brand accent-ring-red-500 accent-ring-[#0f0] bg-red-500"></h1>
addBase base layer: h1, h2 { text-wrap: balance; } addUtilities .no-scrollbar { scrollbar-width: none; } matchUtilities glow-text-md → text-shadow: 0 0 6px rgb(255 0 0 / 0.5) (the colour came from the option in the CSS) glow-text-[9px] works without being listed accent-ring-brand → box-shadow: 0 0 0 3px oklch(0.55 0.2 260) accent-ring-red-500, accent-ring-[#0f0] addVariant hocus:underline → :is(:hover, :focus-visible) matchVariant nth-2:font-bold → :nth-child(2); nth-odd:italic → :nth-child(odd) addComponents .pill → appeared in the UTILITIES layer; "pill p-8" gave padding 32px (utility wins), "pill" alone 4px 12px

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 has text-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 it glow-text-*. Check a name against the built-in list before you ship it.
  • theme() returns the literal value, not a var(). accent-ring-brand compiled to oklch(0.55 0.2 260), whereas the @utility glow-* from earlier compiled to var(--color-brand). A plugin utility therefore does not follow a variable that you re-define per theme (chapter 2). Use @utility with --value() when theming matters.
  • addComponents did not use the components layer in this version: .pill was emitted with the utilities. It was still overridden by p-8 and px-8 (padding read from the browser: 12px/4px alone, 32px left with px-8, 32px all round with p-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 configResultEvidence
theme.extend.colorsworksbg-brand generated
theme.extend.spacingworksp-18 generated
plugins: [function …]worksa plugin utility generated
darkMode: 'class'worksdark: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: [...]ignoredbg-red-500 in the list was not generated. Use @source inline() (chapter 1)
corePlugins: { float: false }ignoredfloat-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.

Mistakes while preparing this chapter
1. A broken --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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

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 type integer, 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:focus stacks, dark, print repeats. Your own unlayered rules still beat utilities
  • @plugin "@tailwindcss/typography" (prose, className renames, not aliases) and @plugin "@tailwindcss/forms" (strategy: class to avoid restyling bare inputs)
  • JS plugin: plugin.withOptions; addBase, addUtilities, matchUtilities, addVariant, matchVariant; theme() gives literal values, not var(); addComponents landed in the utilities layer; avoid built-in names
  • @config: theme.extend, plugins, darkMode and content work; safelist and corePlugins are ignored
Coming next
Chapter 4 goes deep on the variants Tailwind already has: named groups and peers (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.