EmpireUI
Get Pro
← Blog7 min read#tailwind-plugins#tailwind-css#custom-utilities

Building Tailwind Plugins: Custom Utilities, Variants, Components

Learn to build Tailwind CSS plugins from scratch — custom utilities, variants, and components with real code. Stop copy-pasting config workarounds and write it properly.

Code editor showing JavaScript plugin configuration with colorful syntax highlighting

Why Write a Tailwind Plugin at All

Honestly, most developers never write a Tailwind plugin and that's a genuine missed opportunity. You can do a lot inside tailwind.config.js with theme extensions, but the moment you need a utility that computes values dynamically, outputs multiple declarations, or hooks into variants — you've hit the ceiling of plain config.

Plugins let you ship reusable CSS logic as JavaScript. That means version-controlled, tested, shareable. Whether you're building a design system for a team or packaging something like Empire UI's glassmorphism utilities, the plugin API is the right abstraction for the job.

This article walks through the three layers of the plugin API: addUtilities, addVariant, and addComponents. We'll use Tailwind v4.0.2 examples throughout, note where v4 differs from v3, and keep the code practical. No toy examples.

The Plugin API Surface in Tailwind v4

Tailwind v4 shifted a lot of internals to CSS-first configuration, but the JavaScript plugin API remains the right place for dynamic, programmatic output. You import plugin from tailwindcss/plugin and export a function that receives a bag of helpers.

The four helpers you'll use most are addUtilities, addVariant, addComponents, and matchUtilities. The first three take static CSS objects. matchUtilities is where things get interesting — it generates a family of utilities from a theme scale, letting you write .gap-fluid-4 and have it resolve from your theme.

In v4 specifically, the theme() helper inside plugins now resolves CSS custom properties when you've defined them via @theme. That means your plugin can output var(--color-brand-500) rather than a hardcoded hex, keeping it in sync with Tailwind v4's new theming approach. Small thing, big impact when you're dark-mode-aware.

Writing Your First addUtilities Plugin

Start simple. Let's say your team uses a specific glassmorphism card style everywhere — backdrop-filter: blur(12px), background: rgba(255,255,255,0.15), border: 1px solid rgba(255,255,255,0.2). Instead of repeating three classes and a style prop, you wrap it.

// tailwind.config.js
const plugin = require('tailwindcss/plugin')

module.exports = {
  plugins: [
    plugin(function ({ addUtilities }) {
      addUtilities({
        '.glass-card': {
          backdropFilter: 'blur(12px)',
          WebkitBackdropFilter: 'blur(12px)',
          background: 'rgba(255,255,255,0.15)',
          border: '1px solid rgba(255,255,255,0.2)',
          borderRadius: '12px',
        },
        '.glass-card-dark': {
          backdropFilter: 'blur(16px)',
          WebkitBackdropFilter: 'blur(16px)',
          background: 'rgba(0,0,0,0.25)',
          border: '1px solid rgba(255,255,255,0.08)',
          borderRadius: '12px',
        },
      })
    }),
  ],
}

That's it. Now .glass-card is a real utility class, JIT-scanned, purgeable, and composable with responsive prefixes like md:glass-card. You can read more about the glassmorphism design patterns these utilities come from in what is glassmorphism.

Dynamic Utilities with matchUtilities

addUtilities handles static output fine. But what about .text-glow-sm, .text-glow-md, .text-glow-lg? You don't want to hardcode every size variant. matchUtilities solves that.

plugin(function ({ matchUtilities, theme }) {
  matchUtilities(
    {
      'text-glow': (value) => ({
        textShadow: `0 0 ${value} currentColor`,
      }),
    },
    {
      values: theme('textGlow', {
        sm: '4px',
        md: '8px',
        lg: '16px',
        xl: '24px',
      }),
    }
  )
})

Now .text-glow-md outputs text-shadow: 0 0 8px currentColor. Users can also do .text-glow-[12px] with arbitrary values — for free, because matchUtilities wires up the JIT arbitrary value syntax automatically. This is the approach that scales. One function, infinite values.

The values object can pull from your theme config, which means if you add textGlow.xxl: '32px' in theme.extend, the utility appears without touching the plugin itself. That's the composability pattern you want in a design system.

Custom Variants with addVariant

Variants are the selectors and at-rules that prefix your utilities — hover:, dark:, md:. Writing your own opens up scenarios that aren't baked into core. Think rtl:, print:, or UI-state variants tied to your component library's data attributes.

plugin(function ({ addVariant }) {
  // Targets elements inside a `.theme-neon` root
  addVariant('neon', '.theme-neon &')

  // Targets when a sibling input is checked
  addVariant('peer-checked-sibling', ':merge(.peer):checked ~ &')

  // At-rule variant for reduced motion
  addVariant('motion-safe', '@media (prefers-reduced-motion: no-preference)')
})

With the neon variant registered, you can write neon:bg-violet-500 and it'll only activate when a .theme-neon ancestor is in the DOM. That's how theme toggles in React can flip entire visual modes without JavaScript-driven class swaps on every single element — just one class on the root.

The :merge() pseudo in the peer example is a Tailwind v4 internal that handles selector specificity correctly when multiple peer classes stack. Don't skip it — without :merge() the generated selector breaks when two peer variants combine.

Shipping Reusable Component Classes

There's a real debate here. Should component styles live in Tailwind plugins at all, or should they stay as React components? Honestly, both have a place. Plugin-level components make sense when the styling is framework-agnostic — you want the same .btn-primary in a Next.js app and a plain HTML email template.

addComponents works exactly like addUtilities but adds CSS at the components layer, which means utilities can still override them. That's the cascade order you want — component defaults, utility overrides.

plugin(function ({ addComponents, theme }) {
  addComponents({
    '.btn-primary': {
      display: 'inline-flex',
      alignItems: 'center',
      gap: '8px',
      paddingInline: theme('spacing.5', '1.25rem'),
      paddingBlock: theme('spacing.2\.5', '0.625rem'),
      backgroundColor: theme('colors.violet.600', '#7c3aed'),
      color: '#ffffff',
      borderRadius: theme('borderRadius.lg', '0.5rem'),
      fontWeight: '600',
      fontSize: '0.875rem',
      transition: 'background-color 150ms ease',
      '&:hover': {
        backgroundColor: theme('colors.violet.700', '#6d28d9'),
      },
      '&:focus-visible': {
        outline: '2px solid',
        outlineColor: theme('colors.violet.400', '#a78bfa'),
        outlineOffset: '2px',
      },
    },
  })
})

Notice the 8px gap — hardcoded intentionally because it's a design decision, not a scale value. Some things shouldn't be configurable. The theme() calls are there for the values that should track the project's color and spacing scales. That distinction matters when you're writing plugins others will consume.

Packaging and Publishing Your Plugin

So you've built something useful internally. What does it take to publish it? Less than you'd think. The plugin is just a JS module — npm init, point main at your plugin file, and you're done with the basics.

What makes a plugin genuinely reusable is an options object. Wrap your plugin with plugin.withOptions and you get a factory function that consumers call with their config. plugin.withOptions((options = {}) => function({ addUtilities, theme }) { ... }) — that's the pattern. Options let users pass a prefix, a color palette, or toggle features on/off.

Test it properly. Write a small Next.js or Vite project that installs your local plugin with file:../my-plugin, run a build, inspect the output CSS. The Tailwind component patterns article has a good overview of how to structure the consuming side. Make sure your plugin handles the case where users extend your default theme values — matchUtilities handles this naturally, but addUtilities with hardcoded values won't pick up theme extensions automatically.

One more thing: document the exact Tailwind version range you've tested against. The v4 plugin API has a couple of behavioral differences from v3 around how theme() resolves nested keys. Don't make your users debug that.

When Not to Use a Plugin

Plugins add build-time complexity. If you just need a custom color or spacing value, that goes in theme.extend — no plugin needed. If you need a one-off component style, a CSS module or a co-located class is fine. The plugin API is for logic that would otherwise be duplicated across configs.

What about CSS custom properties for things like OKLCH color scales? Most of that belongs in a global CSS file now, especially with Tailwind's OKLCH color support in v4. Plugins can output var(--color-*) references but shouldn't be defining the custom properties themselves — that's @theme territory in v4.

Also worth considering: if your plugin wraps a third-party CSS library (like a chart package or an animation kit), just use addBase to inject it rather than trying to replicate the library's API surface in utilities. Keep the boundary clean.

FAQ

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

Both inject CSS, but they land in different cascade layers. addComponents inserts styles at the components layer, which means utility classes can override them. addUtilities inserts at the utilities layer, so they sit above components. Use addComponents for base styles you expect users to customize with utilities, and addUtilities for atomic helpers that should behave like core Tailwind utilities.

Can I use TypeScript to write a Tailwind plugin?

Yes. Tailwind ships type definitions for the plugin API. Import PluginAPI from 'tailwindcss/types/config' and type your helper arguments. You'll need to either compile your plugin to JS before publishing or list it as a peer dep requiring ts-node. Most teams just compile to CJS — it's simpler.

How do I make my plugin work with Tailwind v4's CSS-first config?

In v4, plugins are referenced via @plugin in your main CSS file: @plugin './my-plugin.js'. The JavaScript plugin function signature is the same, but theme() now resolves against CSS custom properties defined in @theme blocks. Test that your theme() calls still resolve correctly — nested key access like theme('colors.violet.600') works, but some v3 patterns around DEFAULT values behave differently.

How do arbitrary values work with custom matchUtilities plugins?

They work automatically. When you register a utility with matchUtilities, Tailwind's JIT engine hooks up arbitrary value syntax for free. So if you register 'text-glow', users can write text-glow-[20px] without any extra configuration. You can restrict allowed types by passing a type key in the options object, e.g. { type: 'length' } to prevent text-glow-[red] from generating output.

Should I use addBase or addComponents for CSS resets and global styles?

Use addBase for anything that targets raw HTML elements without a class — body defaults, heading resets, link styles. addComponents is for class-based styling. If you're injecting a third-party library's base CSS, addBase is the right layer. Both are inserted before utilities in the cascade, but addBase sits below addComponents.

Can a plugin add its own theme values that other plugins can read?

Not directly through the plugin API itself — plugins are not guaranteed any execution order, so inter-plugin theme sharing is fragile. The right approach is to tell users to add the theme values in their tailwind.config.js under theme.extend, and have your plugin read from there via the theme() helper. Document the expected theme keys your plugin consumes.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Tailwind Animation Utilities: Built-In Classes and Custom KeyframesTailwind Button Collection: 15 Variants for Every Use CaseResponsive Design Systems: Mobile-First Component VariantsDesign Token Pipeline: Figma → Style Dictionary → CSS/Tailwind