Plugins
Load JavaScript plugins with @plugin, use the official typography and forms plugins, and understand why v4's CSS-first APIs replace most plugins outright.
Tailwind v4 still runs JavaScript plugins, but it needs them far less often. @utility and @custom-variant cover in CSS what most plugins existed to do, and two of the four official plugins have been absorbed into the framework.
Overview#
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";No config file, no require, no plugins: [] array. @plugin takes a package name or a path relative to the stylesheet.
Prefer CSS first#
Before adding a plugin, check whether the CSS API already does the job. In v4 it usually does.
@utility content-auto {
content-visibility: auto;
}
@utility tab-* {
tab-size: --value(--tab-size-*, integer, [integer]);
}
@custom-variant any-hover {
@media (any-hover: hover) {
&:hover {
@slot;
}
}
}That is a utility, a functional utility and a variant — the three things the JavaScript plugin API is mostly used for — with no build-tool involvement at all.
| Concern | CSS-first | JavaScript plugin |
|---|---|---|
| Where it lives | Your stylesheet | A .js file plus @plugin |
| Utilities | @utility | addUtilities, matchUtilities |
| Variants | @custom-variant | addVariant, matchVariant |
| Base styles | @layer base | addBase |
| Component classes | @layer components | addComponents |
| Reads the theme | var(--…), --spacing() | The theme() helper |
| Can loop / compute | No | Yes |
| Can read files, call APIs | No | Yes |
| Distributable on npm | Yes, as a CSS file | Yes |
| Editor tooling | Full IntelliSense | Full IntelliSense |
@plugin#
@plugin "package-name";
@plugin "./relative/path.js";
@plugin "package-name" { option: value; }@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "./plugins/tabular-nums.js";Order matters the way @import order matters: a plugin loaded later can override an earlier one's definitions.
Passing options#
The optional block is the CSS equivalent of the object you would have passed in tailwind.config.js:
@plugin "@tailwindcss/forms" {
strategy: "class";
}
@plugin "@tailwindcss/typography" {
className: wysiwyg;
}module.exports = {
plugins: [
require('@tailwindcss/forms')({ strategy: 'class' }),
require('@tailwindcss/typography')({ className: 'wysiwyg' }),
],
}Values are written as CSS values: quoted strings stay quoted, bare identifiers stay bare, numbers are numbers.
The official plugins#
Typography#
@tailwindcss/typography styles HTML you did not write class-by-class — Markdown output, a CMS rich-text field, a blog post from MDX.
npm install -D @tailwindcss/typography@import "tailwindcss";
@plugin "@tailwindcss/typography";<article class="prose">
<h1>Deep-sea creatures</h1>
<p>Sunlight reaches about 200 metres…</p>
<ul><li>Anglerfish</li><li>Gulper eel</li></ul>
</article>One class styles every element inside — headings, paragraphs, lists, blockquotes, <code>, tables, images — with proportional spacing and sensible line lengths.
Sizes. prose-sm, prose-base (the default), prose-lg, prose-xl, prose-2xl. They are responsive-friendly:
<article class="prose md:prose-lg lg:prose-xl">…</article>Colour themes. prose-gray (the default), prose-slate, prose-zinc, prose-neutral, prose-stone, and prose-invert for dark backgrounds:
<article class="prose prose-slate dark:prose-invert">…</article>Element modifiers. prose-<element>: targets one element type inside the block, so you can adjust without leaving your markup:
<article class="prose prose-headings:font-display prose-a:text-indigo-600 prose-a:no-underline hover:prose-a:underline prose-img:rounded-xl">
…
</article>Note the ordering: hover:prose-a:underline, not prose-a:hover:underline. When stacking a prose-* modifier with another variant, the other variant goes last in the chain.
Width. prose sets a max-width tuned for readability. Remove it when the container already controls width:
<article class="prose max-w-none">…</article>Opting out. not-prose sandboxes a subtree — useful for an embedded widget, a code playground or a table you have styled yourself:
<article class="prose">
<p>Styled by the plugin.</p>
<div class="not-prose">
<table>…</table>
</div>
</article>Forms#
@tailwindcss/forms resets browser form controls to a neutral, styleable baseline, so border-gray-300 and rounded-md actually do something on a <select>.
npm install -D @tailwindcss/forms@import "tailwindcss";
@plugin "@tailwindcss/forms";<input type="email" class="w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500" />Two strategies control how the reset is applied:
@plugin "@tailwindcss/forms" {
strategy: "base"; /* global styles for every form element — the default */
}
@plugin "@tailwindcss/forms" {
strategy: "class"; /* no global styles; opt in per element */
}Under the class strategy you opt in explicitly with form-input, form-textarea, form-select, form-multiselect, form-checkbox and form-radio:
<input type="text" class="form-input rounded-md border-gray-300" />
<select class="form-select rounded-md border-gray-300">…</select>Choose class when you have existing form CSS you do not want globally overwritten, or when a third-party component library already styles its own inputs.
Aspect Ratio Obsolete#
@tailwindcss/aspect-ratio existed because aspect-ratio had poor browser support. It has been a core utility for years — do not install the plugin in a v4 project.
<div class="aspect-video">…</div>
<div class="aspect-square">…</div>
<div class="aspect-3/2">…</div>
<div class="aspect-[7/9]">…</div><div class="grid grid-cols-3 gap-4 p-4 text-xs font-medium text-white">
<div class="grid aspect-square place-items-center rounded-lg bg-indigo-600">aspect-square</div>
<div class="grid aspect-video place-items-center rounded-lg bg-indigo-500">aspect-video</div>
<div class="grid aspect-3/2 place-items-center rounded-lg bg-indigo-400">aspect-3/2</div>
</div>Container Queries Obsolete#
@tailwindcss/container-queries is built into v4. Remove the dependency and the @plugin line — the syntax is the same, so the markup usually needs no change.
<div class="@container">
<div class="grid grid-cols-1 @sm:grid-cols-3">…</div>
</div><div class="flex flex-wrap items-start gap-4 p-4">
<div class="@container w-56">
<div class="flex flex-col gap-3 rounded-xl border border-gray-200 bg-white p-3 @sm:flex-row @sm:items-center">
<div class="size-12 shrink-0 rounded-lg bg-indigo-600"></div>
<div><p class="text-sm font-medium text-gray-900">Narrow</p><p class="text-xs text-gray-500">Stacks below 24rem</p></div>
</div>
</div>
<div class="@container w-[28rem]">
<div class="flex flex-col gap-3 rounded-xl border border-gray-200 bg-white p-3 @sm:flex-row @sm:items-center">
<div class="size-12 shrink-0 rounded-lg bg-indigo-600"></div>
<div><p class="text-sm font-medium text-gray-900">Wide</p><p class="text-xs text-gray-500">Same classes, side by side</p></div>
</div>
</div>
</div>v4 adds things the plugin never had: @max-* variants for maximum-width container queries, stacked ranges, and v4.3 @container-size for querying block size. See Container Queries.
Writing your own plugin#
In CSS#
A plugin distributed as a stylesheet is just a file other projects @import:
@utility card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: --spacing(6);
}
@custom-variant acme-dark (&:where([data-acme-theme="dark"] *));@import "tailwindcss";
@import "@acme/ui/tailwind.css";
/* The package ships class names, so make sure they are scanned */
@source "../node_modules/@acme/ui/dist";This is the recommended shape for a shared design system in v4: no build step, no plugin API version to track, and consumers can read exactly what they are getting.
In JavaScript#
Use JavaScript when you need to compute. The plugin function receives an object of helpers:
import plugin from 'tailwindcss/plugin'
export default plugin(function ({ addUtilities }) {
addUtilities({
'.numerals-tabular': { fontVariantNumeric: 'tabular-nums lining-nums' },
'.numerals-oldstyle': { fontVariantNumeric: 'proportional-nums oldstyle-nums' },
})
})@plugin "./plugins/typographic-numerals.js";| Helper | Adds |
|---|---|
addBase | Element defaults, in the base layer |
addComponents | Component classes, in the components layer |
addUtilities | Static utilities |
matchUtilities | Functional utilities that take a value |
addVariant | A static variant |
matchVariant | A variant that takes a value |
theme | Reads a theme value by dot path |
A functional utility generated from data:
import plugin from 'tailwindcss/plugin'
const levels = Object.fromEntries(
Array.from({ length: 8 }, (_, i) => [i + 1, `0 ${i + 1}px ${(i + 1) * 3}px rgb(0 0 0 / ${0.04 + i * 0.01})`]),
)
export default plugin(function ({ matchUtilities }) {
matchUtilities({ elevation: (value) => ({ boxShadow: value }) }, { values: levels })
})<div class="elevation-3 hover:elevation-6">…</div>Eight utilities from four lines of data. Writing those by hand in @utility would be eight near-identical blocks.
Accepting options#
plugin.withOptions wraps the plugin so callers can configure it through @plugin's block:
import plugin from 'tailwindcss/plugin'
const levels = { /* … as above … */ }
export default plugin.withOptions(function (options = {}) {
const prefix = options.className ?? 'elevation'
return function ({ matchUtilities }) {
matchUtilities({ [prefix]: (value) => ({ boxShadow: value }) }, { values: levels })
}
})@plugin "./plugins/elevation.js" {
className: shade;
}Common mistakes#
- Installing
@tailwindcss/container-queriesor@tailwindcss/aspect-ratioin v4. Both are core. The extra dependency at best does nothing and at worst conflicts. - Adding a
plugins: []array to a config file. v4 does not read one unless you load it with@config. Use@plugin. - Forgetting
@sourcefor a package's classes.node_modulesis not scanned by default, so a design-system package's own markup produces no CSS until you register it. - Reaching for a plugin to define three utilities.
@utilityis shorter, has no dependency, and lives next to the theme it uses. - Using
proseon application UI. It restyles every element inside, including ones you wanted plain. prose-a:hover:underline. The other variant goes last:hover:prose-a:underline.- Expecting
proseto respect your container width. It sets its ownmax-width; addmax-w-nonewhen you do not want that.
Related topics#
- Custom Utilities — the CSS-first replacement for most plugins
- Custom Variants —
@custom-variantinstead ofaddVariant - Functions & Directives —
@plugin,@configand@source - Container Queries — now core, no plugin needed
- Forms — styling inputs with and without the plugin