← Blog9 min read#tailwind#plugins#custom

Writing Tailwind CSS Plugins: Custom Utilities, Variants and Components

Learn how to write Tailwind CSS plugins from scratch - custom utilities, variants, and component classes with real code examples and practical plugin patterns.

developer writing code on a laptop screen with colorful syntax highlighting

Why Write a Tailwind Plugin at All?

You've been using Tailwind since at least v2, and you've hit that point where tailwind.config.js has grown into a monster of extend keys. Every project ends up with 20 custom color tokens, a handful of magic px values, and that one backdrop-blur variant you needed three months ago and then copy-pasted into four other projects. Sound familiar?

The plugin API is Tailwind's actual answer to that. It's been stable since v3.0, and v3.4 made the variant API significantly cleaner. Instead of reaching for @layer utilities in a global CSS file every time you need something custom, you encapsulate the logic in a function that plays by Tailwind's rules - JIT-compiled, tree-shakeable, and configurable via your theme().

Honestly, the barrier to writing your first plugin feels higher than it is. The API surface is small. You get four methods: addUtilities, addComponents, addVariants, and matchUtilities. That's basically it. If you've spent any time reading the Tailwind source, you'll notice most of the framework is implemented using exactly these methods internally.

In practice, I write a plugin whenever a pattern shows up in three or more components. Before that threshold, @apply in a component file is fine. After it, a plugin pays for itself immediately - especially if you're building on top of a library like Empire UI where design tokens need to stay consistent across many component variants.

Setting Up the Plugin File

Start in tailwind.config.ts. You can inline plugins directly as anonymous functions, or you can import them from separate files. For anything beyond a one-liner, separate files are the move. Seriously, inline plugins in config files get messy fast.

// tailwind.config.ts
import type { Config } from 'tailwindcss'
import { glassPlugin } from './plugins/glass'
import { focusRingPlugin } from './plugins/focus-ring'

const config: Config = {
  content: ['./src/**/*.{ts,tsx}'],
  plugins: [glassPlugin, focusRingPlugin],
}

export default config

Each plugin file exports a value created by the plugin() factory from tailwindcss/plugin. The factory takes a callback that receives a bag of helpers. Here's a minimal skeleton: ``ts // plugins/focus-ring.ts import plugin from 'tailwindcss/plugin' export const focusRingPlugin = plugin(function ({ addUtilities, theme }) { addUtilities({ '.focus-ring': { '&:focus-visible': { outline: 2px solid ${theme('colors.violet.500')}, outlineOffset: '2px', }, }, }) }) ``

Worth noting: theme() inside the plugin callback is a function, not the object you access in your config. You call theme('colors.violet.500') and it resolves to the actual hex value at build time. If you've ever seen Cannot read properties of undefined reading '500' in a plugin, that's almost always a path string typo - not a structural problem.

One more thing - you can also export a second argument to the plugin() factory: a theme extension object. This lets you ship default theme values alongside your plugin, which is incredibly useful when you're building something others will install as an npm package.

addUtilities: Static and Matched

addUtilities is the simplest method. Pass it a plain CSS object, and Tailwind registers those classes. The keys are class selectors, the values are CSS property objects. You can nest selectors using & exactly like you would in SCSS or PostCSS.

Static utilities are great for things that don't need a value scale - think .text-balance, .isolate, or a custom .gpu class that enables hardware acceleration: ``ts addUtilities({ '.gpu': { transform: 'translateZ(0)', willChange: 'transform', }, '.text-balance': { textWrap: 'balance', }, }) ``

For utilities that need to respond to a value from your theme - like blur-{n} or shadow-{size} - you want matchUtilities instead. This is the 2023 addition that most tutorials skip and then people wonder why their JIT isn't picking up their custom utility with arbitrary values. ``ts import plugin from 'tailwindcss/plugin' export const glassPlugin = plugin(function ({ matchUtilities, theme }) { matchUtilities( { 'glass-blur': (value) => ({ backdropFilter: blur(${value}), WebkitBackdropFilter: blur(${value}), }), }, { values: theme('blur') } ) }) ``

With that registered, glass-blur-md and glass-blur-[14px] both work - the arbitrary value syntax comes for free. This is what makes matchUtilities the right tool any time you're mapping your utility to a theme scale. If you're building glassmorphism components, this pattern is exactly what you need to keep blur values in sync with your design tokens rather than hardcoding blur(12px) in forty places.

Quick aside: the type option in matchUtilities lets you declare what CSS type the value represents ('length', 'color', 'percentage'). Without it, Tailwind can't validate arbitrary values properly. Add { values: theme('blur'), type: 'length' } and you'll get correct warnings when someone passes glass-blur-[red].

addVariants: Custom State and Context Selectors

Variants are arguably more powerful than utilities. A single variant can modify every existing utility class - hover:, dark:, focus-visible: are all implemented the exact same way you'd write your own. When you need a variant Tailwind doesn't ship, you write it.

Let's say you're building a component library and you want a selected: variant that applies when a parent has data-selected="true". Common in comboboxes, listboxes, tree views: ``ts import plugin from 'tailwindcss/plugin' export const selectedVariantPlugin = plugin(function ({ addVariant }) { addVariant('selected', '&[data-selected="true"]') addVariant('group-selected', ':merge(.group)[data-selected="true"] &') }) ``

Now selected:bg-violet-100 and group-selected:text-white work in your JSX just like hover: does. No CSS specificity fights, no extra class names, no runtime conditionals. The variant does the work. ``tsx <li data-selected={isSelected ? 'true' : undefined} className="selected:bg-violet-100 group-selected:font-semibold px-3 py-2 rounded-lg" > {label} </li> ``

You can also write multi-selector variants using arrays and the functional form. This is useful when a single concept maps to multiple CSS selectors - e.g. a can-hover: variant that only applies on devices that support hover, similar to the @media (hover: hover) media query that Tailwind itself ships as @hover: in v4: ``ts addVariant('can-hover', '@media (hover: hover) and (pointer: fine)') ``

In practice, I'd say 80% of the custom variants I write are data-* attribute variants for headless UI state. If you're using Radix UI, Headless UI, or any library that communicates state through data attributes, building these variants once in a plugin is miles better than writing data-[state=open]: inline everywhere. Look at a 200px-wide viewport on mobile and tell me those inline data attribute selectors are readable.

addComponents: Opinionated Class Bundles

Think of addComponents as @layer components but in JavaScript. The key difference is specificity: components land in the components layer, which means utility classes can still override them. This is the correct behavior - you want btn to define defaults, and text-lg to be able to override the font size.

Here's a real example: a glass card component you might use across a UI that includes glassmorphism generator output: ``ts import plugin from 'tailwindcss/plugin' export const glassCardPlugin = plugin(function ({ addComponents, theme }) { addComponents({ '.glass-card': { backgroundColor: 'rgba(255, 255, 255, 0.08)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid rgba(255, 255, 255, 0.15)', borderRadius: theme('borderRadius.2xl'), padding: theme('spacing.6'), boxShadow: '0 8px 32px rgba(0, 0, 0, 0.24)', }, '.glass-card-sm': { backgroundColor: 'rgba(255, 255, 255, 0.06)', backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)', border: '1px solid rgba(255, 255, 255, 0.12)', borderRadius: theme('borderRadius.xl'), padding: theme('spacing.4'), }, }) }) ``

Worth noting: addComponents supports nested selectors and pseudo-classes just like addUtilities. You're not limited to flat CSS objects: ``ts addComponents({ '.btn-ghost': { padding: ${theme('spacing.2')} ${theme('spacing.4')}, borderRadius: theme('borderRadius.md'), fontWeight: theme('fontWeight.medium'), transition: 'all 150ms ease', '&:hover': { backgroundColor: theme('colors.slate.100'), }, '.dark &': { '&:hover': { backgroundColor: theme('colors.slate.800'), }, }, }, }) ``

The dark mode nesting there works fine in JIT. The .dark & selector is the class strategy equivalent - if you're on media strategy, swap it for @media (prefers-color-scheme: dark). Either way, the CSS output is exactly what you'd expect.

One gotcha: addComponents doesn't support arbitrary values the way matchUtilities does. If you need value-parameterized components, you still have to use matchUtilities and just return more CSS properties. It's a bit verbose, but the JIT behavior is worth it if you genuinely need runtime values.

Shipping a Configurable Plugin

A plugin that's hardcoded to your project's values isn't reusable. The plugin.withOptions() form lets you accept a config object and return different behavior depending on it. This is how @tailwindcss/forms and @tailwindcss/typography work.

import plugin from 'tailwindcss/plugin'

interface GlassPluginOptions {
  prefix?: string
  defaultBlur?: string
  defaultOpacity?: number
}

export const glassPlugin = plugin.withOptions<GlassPluginOptions>(
  (options = {}) =>
    ({ addUtilities, addComponents }) => {
      const prefix = options.prefix ?? 'glass'
      const blur = options.defaultBlur ?? '12px'
      const opacity = options.defaultOpacity ?? 0.08

      addComponents({
        [`.${prefix}-card`]: {
          backgroundColor: `rgba(255, 255, 255, ${opacity})`,
          backdropFilter: `blur(${blur})`,
          WebkitBackdropFilter: `blur(${blur})`,
        },
      })
    }
)

With withOptions, users can call glassPlugin({ prefix: 'frost', defaultBlur: '16px' }) in their config array and get a fully customized output. No forking, no patching. If you're shipping a design system that teams configure per-project, this pattern saves everyone a lot of pain.

That said, keep your options surface small. Every option you expose is documentation you have to write and a decision the user has to make. Start with zero options, extract to withOptions when a second project needs a different default. YAGNI applies to plugin APIs too.

If you're building something like the components in Empire UI where teams apply the same component across multiple brand themes, withOptions combined with CSS custom properties inside the plugin output is a solid combo - the plugin sets up the structure, the CSS variables handle the brand layer on top.

Testing and Debugging Plugins

Debugging a Tailwind plugin without running the full build is annoying. The fastest feedback loop is a small test file - literally a test.html with a CDN Tailwind that imports your config, or a tiny Vite app that runs your config and outputs the CSS. You don't need Jest for this.

For unit-level testing, the @tailwindcss/jest package exists but it's overkill for most plugin work. Instead, use the programmatic API directly: ``ts import { execSync } from 'child_process' import { writeFileSync, readFileSync } from 'fs' // write a minimal HTML file, generate CSS, parse the output const output = execSync( 'npx tailwindcss -i input.css -o /dev/stdout --content test.html', { encoding: 'utf-8' } ) console.log(output.includes('.glass-card')) ``

That's crude but fast. In 2026, Vite's module runner also makes it possible to write proper unit tests that import your plugin, instantiate it with mock addUtilities spies, and assert the CSS-in-JS objects directly. That's the approach I'd take for a plugin you're shipping as an npm package.

Look, the biggest mistake I see is people writing a plugin and then not including it in the content scan. Remember: Tailwind only generates CSS for classes it finds in your content files. If you're adding .glass-card via a plugin but you only have static strings in the component, you're fine - Tailwind scans for the string glass-card regardless of where the rule is defined. But if you're building the class name dynamically (${prefix}-card), that class won't appear in any content file and it'll be stripped. Use the safelist option for those cases.

FAQ

What's the difference between addUtilities and addComponents in Tailwind plugins?

They land in different layers - addUtilities goes into the utilities layer, addComponents into the components layer. This means utilities can always override component styles, which is usually what you want. Use addComponents for multi-property design patterns like buttons or cards, and addUtilities for single-purpose helpers.

Can I use arbitrary values with my custom plugin utilities?

Only if you use matchUtilities instead of addUtilities. With matchUtilities, your custom class automatically supports the square-bracket arbitrary value syntax - so my-util-[42px] just works without any extra code.

How do I make my Tailwind plugin configurable per project?

Use plugin.withOptions() instead of plugin(). It wraps your plugin factory in a function that accepts an options object, so consumers can call yourPlugin({ prefix: 'custom', blur: '16px' }) in their config's plugins array.

Why are my plugin-generated classes getting purged in production?

If the class name is assembled dynamically inside your plugin (like template literals), the string won't appear in your content files and JIT will strip it. Add those class names explicitly to the safelist array in your Tailwind config.

Free components in 41 styles
React & Tailwind, copy-paste ready.
Browse β†’

Read next

Tailwind CSS Mastery: Every Utility, Plugin, and Pattern in One Guide β†’Writing a Tailwind CSS Plugin: addComponents, addUtilities, matchUtilities β†’Card Component Variants in Tailwind: 10 Patterns for Every Use Case β†’What Is Glassmorphism? A Free React + Tailwind Guide β†’