Writing a Tailwind CSS Plugin: addComponents, addUtilities, matchUtilities
Learn how to write Tailwind CSS plugins from scratch - addComponents, addUtilities, matchUtilities, and theme() access explained with real working code.
Why Write a Plugin at All?
At some point, every serious Tailwind project hits the same wall. You've been copying the same bg-white/10 backdrop-blur-md border border-white/20 rounded-2xl string across thirty components, and @apply in a CSS file feels like going backwards. That's when you write a plugin.
Tailwind plugins let you ship reusable CSS - components, utilities, base styles - through JavaScript. They're tree-shaken by PurgeCSS/Content scanning just like built-in utilities, they respect your theme tokens, and they compose with the variant system (hover, dark mode, responsive) for free. Nothing special to wire up.
In practice, there are three plugin APIs you'll actually reach for: addComponents for opinionated multi-property classes (think .btn-primary), addUtilities for single-purpose helpers that aren't in core Tailwind yet, and matchUtilities for dynamic utilities that accept values from your theme - like blur-{amount} or glow-{color}. Knowing which one to reach for is 80% of the job.
Worth noting: plugins shipped in v3.3+ have access to the CSS-in-JS object syntax with full PostCSS nesting support. If you're still on v3.0 or earlier, upgrade first - the DX difference is not subtle.
Plugin Boilerplate and File Structure
The entry point is plugin.withOptions or the bare plugin export from tailwindcss/plugin. Use withOptions any time your plugin needs user configuration. Otherwise the simpler form is fine.
// tailwind.config.js (or tailwind.config.ts)
import plugin from 'tailwindcss/plugin'
export default {
content: ['./src/**/*.{ts,tsx}'],
plugins: [
plugin(function ({ addComponents, addUtilities, matchUtilities, theme }) {
// your code here
}),
],
}For anything non-trivial, pull it into its own file. I usually put plugins under src/lib/tailwind/ or a plugins/ directory at the project root. The callback receives a helpers object - destructure only what you need so it's readable.
// plugins/glass.ts
import plugin from 'tailwindcss/plugin'
export const glassPlugin = plugin(function ({ addComponents, theme }) {
// implementation below
})Then in your config: plugins: [glassPlugin]. Clean, testable, and easy to publish to npm later if you want.
addComponents - Fixed Multi-Property Classes
addComponents is for component-level abstractions: multi-property CSS classes that don't need to vary by value. Think button variants, card shells, badge styles. They generate static class names Tailwind can scan and include.
addComponents({
'.glass-card': {
backgroundColor: 'rgba(255, 255, 255, 0.10)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)', // Safari
border: '1px solid rgba(255, 255, 255, 0.20)',
borderRadius: '1rem',
padding: '1.5rem',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12)',
},
'.glass-card-dark': {
backgroundColor: 'rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: '1rem',
padding: '1.5rem',
},
})Honestly, the biggest trap with addComponents is over-encoding design decisions. If you hardcode border-radius: 1rem here, you lose the ability to override it with rounded-none in your JSX - Tailwind's utility classes have higher specificity than component classes in the cascade. If you want composability, use CSS custom properties inside the component and let utilities override the variables instead.
addComponents({
'.glass-card': {
'--glass-blur': '12px',
'--glass-bg': 'rgba(255,255,255,0.10)',
backgroundColor: 'var(--glass-bg)',
backdropFilter: 'blur(var(--glass-blur))',
WebkitBackdropFilter: 'blur(var(--glass-blur))',
},
})Now class="glass-card [--glass-blur:24px]" just works in Tailwind v3.3+ arbitrary property syntax. Empire UI uses this exact pattern for its glassmorphism components - token-based glass surfaces that stay overridable at the point of use.
addUtilities - One-Off Helpers
addUtilities is for adding new single-purpose utility classes that core Tailwind doesn't ship. Think writing-mode, contain, text-stroke, or that one scroll-snap-align combination you need every project. Each entry is a class name mapped to a CSS object.
addUtilities({
'.writing-vertical': {
writingMode: 'vertical-rl',
textOrientation: 'mixed',
},
'.contain-paint': {
contain: 'paint',
},
'.scrollbar-hide': {
'&::-webkit-scrollbar': { display: 'none' },
'-ms-overflow-style': 'none',
'scrollbar-width': 'none',
},
})That nested &::-webkit-scrollbar syntax works because Tailwind's plugin system runs PostCSS nesting under the hood as of v3.1. You get pseudo-elements, pseudo-classes, media queries - the full PostCSS CSS-in-JS object syntax. No raw CSS strings needed.
Quick aside: Tailwind automatically generates responsive and hover variants for utilities you add via addUtilities. So md:scrollbar-hide, dark:writing-vertical - all work without any extra config on your end. If you want to *disable* variant generation (rare), pass { respectPrefix: false } as the second argument.
Look, you can also reference your theme values directly inside addUtilities using the theme() helper that's passed to the plugin callback. theme('colors.violet.500') returns #8b5cf6 (or whatever your config says). This is how you keep utilities in sync with design tokens instead of hardcoding hex values all over the place.
matchUtilities - Dynamic Value-Based Utilities
This is the interesting one. matchUtilities generates utilities that accept a value - either from your theme or as an arbitrary value via the [...] syntax. It's how Tailwind itself implements text-{size}, p-{n}, shadow-{name}, and so on.
matchUtilities(
{
'glow': (value) => ({
boxShadow: `0 0 20px 4px ${value}`,
}),
},
{ values: theme('colors') }
)With that registered, glow-violet-500 generates box-shadow: 0 0 20px 4px #8b5cf6. You get every color in your theme as a valid variant - including custom colors you've added. And glow-[#ff0099] works too via arbitrary value support. That's a lot of expressiveness for maybe 6 lines of plugin code.
// More sophisticated: glow with opacity modifier support
matchUtilities(
{
glow: (value) => ({
'--glow-color': value,
boxShadow: '0 0 20px 4px var(--glow-color)',
}),
},
{
values: flattenColorPalette(theme('colors')),
type: ['color', 'any'],
}
)The type option tells Tailwind which CSS value types are valid so it can power autocomplete in IDEs. Pass 'color' and editors that understand Tailwind's IntelliSense plugin will show a color swatch picker. You'd use 'length' for pixel values, 'number' for unitless numbers, etc. The box shadow generator on Empire UI uses a similar approach for its live preview - parameterized shadow tokens that map directly to plugin-generated utilities.
One more thing - matchUtilities also accepts a modifiers option. In Tailwind v3.2+, you can support the utility/modifier syntax (like bg-white/50 for opacity). Your resolver receives both the value and the modifier, which opens up two-axis parameterization. glow-violet-500/80 could set both color and spread radius, for example.
Accessing Theme Values and Config
The plugin callback passes theme, config, e (CSS class name escaper), and corePlugins. You'll use theme the most. It's a function that resolves dot-notation paths into your resolved config values - including any extend merges.
plugin(function ({ addComponents, theme }) {
const fonts = theme('fontFamily')
const radii = theme('borderRadius')
addComponents({
'.card-mono': {
fontFamily: fonts.mono.join(', '),
borderRadius: radii.xl,
// ...
},
})
})Use theme('spacing.4') instead of '1rem' if you want your plugin to respect users who've overridden the default spacing scale. This is the difference between a plugin that works in your project and one that actually ships on npm and works in everyone else's.
In practice, you'll also want flattenColorPalette from tailwindcss/src/util/flattenColorPalette for anything color-related. It collapses the nested color object ({ violet: { 500: '#8b5cf6' } }) into a flat map ({ 'violet-500': '#8b5cf6' }) that matchUtilities can consume directly as its values option. Import it at the top of your plugin file - it's not exported from the main package so you have to grab it from the internals path, but that path has been stable since Tailwind 3.0 (2021).
import flattenColorPalette from 'tailwindcss/src/util/flattenColorPalette'
plugin(function ({ matchUtilities, theme }) {
matchUtilities(
{ neon: (val) => ({ textShadow: `0 0 8px ${val}, 0 0 20px ${val}` }) },
{ values: flattenColorPalette(theme('colors')), type: 'color' }
)
})That neon-* utility then pairs perfectly with a cyberpunk or vaporwave design system - exactly the kind of thing you'd build once as a plugin and drop into any project that uses that aesthetic.
Shipping, Testing, and Common Mistakes
If you're publishing to npm, wrap your plugin in plugin.withOptions so users can configure it. The factory receives user options and returns the standard plugin function. This is how @tailwindcss/typography works - require('@tailwindcss/typography')({ className: 'prose' }).
import plugin from 'tailwindcss/plugin'
export const glowPlugin = plugin.withOptions(
function (options = {}) {
const { prefix = 'glow', defaultSpread = '20px' } = options
return function ({ matchUtilities, theme }) {
matchUtilities(
{
[prefix]: (value) => ({
boxShadow: `0 0 ${defaultSpread} 4px ${value}`,
}),
},
{ values: flattenColorPalette(theme('colors')), type: 'color' }
)
}
}
)
// Usage: plugins: [glowPlugin({ prefix: 'g', defaultSpread: '30px' })]For testing, spin up a minimal tailwind.config.js, pass your plugin in, and use @tailwindcss/forms's test setup or just run postcss in a test file and assert the generated CSS output contains the expected selectors. It's not glamorous but it catches regressions fast.
The most common mistake? Forgetting that addComponents classes have lower specificity than utilities. If you do addComponents({ '.btn': { color: 'red' } }) and then write class="btn text-blue-500", the text will be blue - that's correct behavior, not a bug. Design your components around it.
Second most common: writing addUtilities when you should be writing matchUtilities. If you find yourself generating ten nearly-identical utility classes that only differ by a value, stop and reach for matchUtilities instead. Twenty lines of addUtilities collapses to five lines of matchUtilities plus a theme extension.
FAQ
addComponents is for multi-property component classes (.btn, .card) that represent opinionated abstractions. addUtilities is for single-purpose helpers that don't exist in Tailwind core yet. The key difference is intent - and the fact that component styles have lower specificity, so utilities can override them.
Yes, that's the whole point. Any utility registered with matchUtilities automatically supports the [value] arbitrary syntax for free. Pass type: ['color', 'any'] in the options object to get IDE color picker support on top of it.
Use the theme() helper passed to the plugin callback - theme('colors.violet.500') gives you the resolved value. For the full flattened color map (useful with matchUtilities), import flattenColorPalette from 'tailwindcss/src/util/flattenColorPalette'.
Yes, automatically. Tailwind generates all configured variants for utilities added via addUtilities and matchUtilities without any extra configuration on your end.
