Authoring Tailwind CSS v4 plugins
Hi, Iâm the creator of a Tailwind CSS plugin for Googleâs Material Symbols that I made before Tailwind CSS v4 was released.
Recently, I finally got around to updating it.
To do that, I had to understand where plugins fit into the new world of Tailwind CSS.
One thing is for sure: JavaScript plugins are deprecated, and the new version is all about a CSS-first approach.
It does have a compatibility layer, which means people can still use the @plugin directive in CSS with legacy JavaScript plugins, but whatâs the fun in that?
I started looking at the customization options offered with v4 and noticed some especially useful new tools that build on old concepts: @theme and @utility.
The first is used for customizing your theme variables, and the second is for creating static and functional utilities, exactly what I needed!
What follows are my findings on how some concepts from v3 JavaScript plugins translate to the new CSS-first approach with v4 and an example plugin implementation.
Plugin config and user overrides
Plugins typically need to provide sensible defaults while allowing users to customize or extend their behavior through configuration.
With JavaScript plugins, you can achieve this by reserving a namespace in the userâs theme
configuration, for example myPlugin:
import myPlugin from "my-plugin";
export default { theme: { extend: { // myPlugin: { sizes: { sm: 12, md: 24, }, }, }, }, // plugins: [myPlugin],};and access values from that namespace in your plugin, using Tailwindâs theme() function:
import plugin from "tailwindcss/plugin";
// Plugin implementationexport default plugin(({ theme }) => { const myPluginConfig = theme("myPlugin"); console.log(myPluginConfig.sizes.sm); // prints 12});This covers exposing a way for users to customize the default configuration of your plugin and reading those custom values, but how do you provide the default configuration to begin with? Thatâs easy as well. You can just pass an object as the second argument to the plugin function:
import plugin from "tailwindcss/plugin";
// Plugin implementationexport default plugin( ({ theme }) => { const myPluginConfig = theme("myPlugin"); console.log(myPluginConfig.sizes.sm); // prints 12 }, // Default config { theme: { myPlugin: { sizes: { sm: 12, md: 24, }, }, }, },);As you can see this follows the same shape as the config, and so any properties provided by the user would overwrite these defaults.
Now, to achieve the same functionality in v4, we need to use the new @theme directive.
The userâs config in CSS would look like this:
@import "tailwindcss";@import "my-plugin";
@theme { --my-plugin-sizes-sm: 12; --my-plugin-sizes-md: 24;}In CSS, plugin configuration uses CSS custom properties (variables).
We can make use of the same namespacing technique.
You can flatten the nested structure from JavaScript using dashes as separators.
For example, the JavaScript config path myPlugin.sizes.sm can be written as --my-plugin-sizes-sm.
This naming pattern maintains the same hierarchical organization while being compatible with CSS syntax.
Then, you can access these configuration values using CSSâs built-in var() function.
This allows plugins to read the user-provided configuration and use those values when generating styles.
For example, to access the sm size value from the configuration above, you would use:
/* Plugin implementation */font-size: var(--my-plugin-sizes-sm); /* resolves to 12 */What about providing some default values, you ask? Well, we use the same @theme directive:
/* Default config */@theme { --my-plugin-sizes-sm: 12; --my-plugin-sizes-md: 24;}
/* Plugin implementation */font-size: var(--my-plugin-sizes-sm); /* resolves to 12 */And these values, just like in the JS version would be overwritten by declarations with the same name in the userâs theme config.
So now, you have a way to provide defaults to your users while still allowing customizations that suit their own system. Next, we will look into how you can put these to use by providing custom utilities.
Utilities, static and functional
This is where the bulk of your implementation lies. Here is where you add value by providing styles through utility classes.
Tailwind has two kinds of utilities: static and functional.
Static utilities provide users with a single predefined class like icon,
while functional utilities offer a dynamic set of classes that build on top of a predefined prefix, such as icon-.
Static utilities
For static utilities in JS plugins, Tailwind exposes an addUtilities() function that you can use. You need to pass it an object where the key is the class that can be used to apply the styles defined under it:
import plugin from "tailwindcss/plugin";
// Plugin implementationexport default plugin(({ addUtilities }) => { addUtilities({ ".icon": { fontFamily: "'Material Symbols Rounded'", }, });});This would expose an icon class to your pluginâs users that applies the "'Material Symbols Rounded'" font family to the element using it.
You can achieve the same functionality in v4 using the new @utility directive
combined with a name that will represent the class used to apply the styles defined by it:
@utility icon { font-family: "Material Symbols Rounded";}Static utilities are useful for setting up a base style that can be extended using functional utilities, which we will look into next.
Functional utilities
To define a utility that accepts user âargumentsâ or maps over your config keys,
you need to use the matchUtilities() function in JS plugins:
import plugin from "tailwindcss/plugin";
// Plugin implementationexport default plugin(({ matchUtilities }) => { matchUtilities( { icon: (value) => ({ fontSize: value, }), }, { values: { sm: 20, md: 40, }, }, );});This setup will allow users to make use of icon-sm and icon-md utility classes,
to apply a font size of 20 and 40 respectively to their elements.
The way this works is that the match function detects classes starting with icon- and treats what comes after - as an âargumentâ.
For example sm, then it looks up the actual value for that âargumentâ in the values object and invokes your icon function with it.
Imagine it doing icon(values.sm) behind the scenes.
With v4 in CSS, you use the same @utility directive from before,
but this time with a special name such as icon-*.
Similar to the JS version, icon- is what will be matched, and the * serves as a placeholder for an âargumentâ to be passed in by the user.
/* Default config */@theme { --my-plugin-sizes-sm: 20; --my-plugin-sizes-md: 40;}
/* Plugin implementation */@utility icon-* { font-size: --value(--my-plugin-sizes-*);}But youâll notice another thing here: --value().
This is a special function provided by Tailwind that allows you to resolve the argument passed in by the user
and map it to a value from your config.
In this case icon-sm would translate into --value(--my-plugin-sizes-sm) which in turn will set the font-size equal to 20.
Using both together
To create the full implementation of your plugin, usually you need both static and functional utilities: static utilities to provide a base set of styles and functional ones to add on top of those base styles.
import plugin from "tailwindcss/plugin";
// Plugin implementationexport default plugin(({ addUtilities, matchUtilities }) => { // Static utilities addUtilities({ ".icon": { fontFamily: "'Material Symbols Rounded'", }, }); // Functional utilities matchUtilities( { icon: (value) => ({ fontSize: value, }), }, { values: { sm: 20, md: 40, }, }, );});And in v4, you can do the same thing like this:
/* Default config */@theme { --my-plugin-sizes-sm: 20; --my-plugin-sizes-md: 40;}
/* Plugin implementation *//* Static utilities */@utility icon { font-family: "Material Symbols Rounded";}/* Functional utilities */@utility icon-* { font-size: --value(--my-plugin-sizes-*);}This allows users of your plugin to apply only the font family using the icon base class, then update the font size using icon-sm or icon-md.
<span class="icon icon-sm">star</span><!-- font-family: "'Material Symbols Rounded'"; font-size: 20px; -->Wrapping up
Here is a simple implementation of a user-configurable plugin in JS for v3 with static, functional utilities and a default config:
import plugin from "tailwindcss/plugin";
// Plugin implementationexport default plugin( ({ theme, addUtilities, matchUtilities }) => { const myPluginConfig = theme("myPlugin"); addUtilities({ ".icon": { fontFamily: "'Material Symbols Rounded'", }, }); matchUtilities( { icon: (value) => ({ fontSize: value, }), }, // { values: myPluginConfig.sizes, }, ); }, // Default config { theme: { myPlugin: { sizes: { sm: 12, md: 24, }, }, }, },);and here is the same implementation using the new CSS-first approach in Tailwind CSS v4:
/* Default config */@theme { --my-plugin-sizes-sm: 12; --my-plugin-sizes-md: 24;}
/* Plugin implementation */@utility icon { font-family: "Material Symbols Rounded";}
@utility icon-* { font-size: --value(--my-plugin-sizes-*);}As you can see, the CSS version is much more compact and easier to read once you understand how it works.
And thatâs a wrap for how to actually implement a plugin. If youâre interested in how you can distribute it and support users of both Tailwind CSS v3 and v4 with the same NPM package, you might want to keep reading.
Plugin distribution
I wanted to have support for both Tailwind CSS versions. The first thing to figure out was how to provide two seemingly different things through the same NPM package export.
That turned out to be extremely simple! If you didnât know, NPM is not only for JavaScript files. You can host pretty much any file type in your packages, including CSS.
With the help of conditional exports
and Tailwindâs CSS custom resolver
that accounts for the "style" key, you can have one public entry point for both the JS and CSS version.
In my case, I wanted users who import the plugin from JavaScript to get the v3 JS plugin.
Users who import it from CSS though, should get the v4 CSS-first plugin.
That can be done with the following package.json config:
"exports": { ".": { "style": "./dist/index.css", "import": "./dist/index.js" }}Conditional exports allow you to serve a different file from your package based on where and how the user is importing it, in short, the environment.
So using import myPlugin from "my-plugin" in JS will import the JavaScript file,
and using @import "my-plugin" in CSS will import the CSS file.
The "style" key is a convention used by bundlers to detect CSS exports.
If you are worried about that not being supported by your users environment,
you can provide a fallback through sub-path exports:
"exports": { ".": { "style": "./dist/index.css", "import": "./dist/index.js" }, "./css": "./dist/index.css"}So for environments that donât support the conditional "style" key,
your users can import the plugin in CSS using @import "my-plugin/css".
Hooray, youâre done! I hope this was a good read and gave you some insight into how you might approach re-writing legacy JavaScript plugins. Or, even, create new CSS-first ones, to offer a standard set of customizations for Tailwind CSS projects.
If you want to see a complete example, check out my plugin for Material Symbols:
đ A Tailwind CSS plugin that simplifies working with Google's Material Symbols font by providing `icon` utility classes for easy icon integration and styling.
- #fortheloveofcode
- #material-icons
- #material-symbols
- #tailwindcss
- #tailwindcss-plugin
- #FirstPostEver
- #TailwindCSS
- #ForTheLoveOfCode