Tailwind CSS Handbook v4.3

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#

app.css
@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.

What a plugin used to be needed for
@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.

ConcernCSS-firstJavaScript plugin
Where it livesYour stylesheetA .js file plus @plugin
Utilities@utilityaddUtilities, matchUtilities
Variants@custom-variantaddVariant, matchVariant
Base styles@layer baseaddBase
Component classes@layer componentsaddComponents
Reads the themevar(--…), --spacing()The theme() helper
Can loop / computeNoYes
Can read files, call APIsNoYes
Distributable on npmYes, as a CSS fileYes
Editor toolingFull IntelliSenseFull IntelliSense

@plugin#

Syntax
@plugin "package-name";
@plugin "./relative/path.js";
@plugin "package-name" { option: value; }
CSS
@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:

CSS
@plugin "@tailwindcss/forms" {
  strategy: "class";
}

@plugin "@tailwindcss/typography" {
  className: wysiwyg;
}
Legacy — the v3 equivalent
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.

BASH
npm install -D @tailwindcss/typography
app.css
@import "tailwindcss";
@plugin "@tailwindcss/typography";
HTML
<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:

HTML
<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:

HTML
<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:

HTML
<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:

HTML
<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:

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

BASH
npm install -D @tailwindcss/forms
CSS
@import "tailwindcss";
@plugin "@tailwindcss/forms";
HTML
<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:

CSS
@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:

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

HTML
<div class="aspect-video">…</div>
<div class="aspect-square">…</div>
<div class="aspect-3/2">…</div>
<div class="aspect-[7/9]">…</div>
Loading preview…

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.

HTML
<div class="@container">
  <div class="grid grid-cols-1 @sm:grid-cols-3">…</div>
</div>
Loading preview…

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:

node_modules/@acme/ui/tailwind.css
@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"] *));
A consumer's app.css
@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:

plugins/typographic-numerals.js
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' },
  })
})
CSS
@plugin "./plugins/typographic-numerals.js";
HelperAdds
addBaseElement defaults, in the base layer
addComponentsComponent classes, in the components layer
addUtilitiesStatic utilities
matchUtilitiesFunctional utilities that take a value
addVariantA static variant
matchVariantA variant that takes a value
themeReads a theme value by dot path

A functional utility generated from data:

plugins/elevation.js
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 })
})
HTML
<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:

plugins/elevation.js
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 })
  }
})
CSS
@plugin "./plugins/elevation.js" {
  className: shade;
}

Common mistakes#

  • Installing @tailwindcss/container-queries or @tailwindcss/aspect-ratio in 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 @source for a package's classes. node_modules is 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. @utility is shorter, has no dependency, and lives next to the theme it uses.
  • Using prose on 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 prose to respect your container width. It sets its own max-width; add max-w-none when you do not want that.