SuperAPI AI Directory

tailwind-impl-plugins-custom

Frontend & websites

View repository

Updated 10-15 22:18

About this skill

Tailwind CSS Custom Plugins

Tailwind exposes three authoring paths for custom styles : the JS plugin() API (works in v3 and v4 for back-compat), v4's CSS-native @utility directive, and v4's @custom-variant directive. Pick by constraint, not by taste.

Companion skills :

  • tailwind-impl-config-v4 : @utility, @custom-variant, @plugin directives in depth
  • tailwind-impl-config-v3 : tailwind.config.js surface
  • tailwind-core-v3-vs-v4 : version-wide API differences
  • tailwind-syntax-functional-utilities : v4 --value() / --modifier()

Decision : Which Authoring Path

Constraint Path
Project on v3 JS plugin() only
Project on v4 only, no v3 back-compat CSS @utility + @custom-variant
Project on v4 with @plugin loading legacy plugins JS plugin() via @plugin "./my-plugin.js"
Plugin must support BOTH v3 and v4 JS plugin() (works in both)
Plugin needs runtime config from caller plugin.withOptions(fn, configFn)
Plugin only adds tokens, no utilities @theme { ... } directly, no plugin

Quick Reference : JS plugin() Signature

const plugin = require('tailwindcss/plugin')

module.exports = plugin(function ({
  addUtilities,
  addComponents,
  addBase,
  addVariant,
  matchUtilities,
  matchComponents,
  matchVariant,
  theme,
  config,
  corePlugins,
  e,
}) {
  // body
})
Helper Purpose Variants apply Theme access
addUtilities Static utility classes yes via theme() arg
addComponents Multi-utility component classes yes (under v4) via theme() arg
addBase Resets, @font-face, element selectors NO via theme() arg
addVariant(name, selector) Static variant n/a n/a
matchUtilities(map, opts) Functional utilities with arbitrary-value support yes opts.values
matchComponents(map, opts) Functional components yes opts.values
matchVariant(name, fn, opts) Parameterised variants (nth-*, data-*) n/a opts.values
theme(path) Read theme value ('colors.red.500') n/a direct
config(key) Read full config value n/a n/a
corePlugins(name) Test whether a core plugin is enabled n/a n/a
e(str) Escape a string for safe CSS class names n/a n/a

Pattern 1 : Static Utility (addUtilities)

const plugin = require('tailwindcss/plugin')

module.exports = plugin(function ({ addUtilities }) {
  addUtilities({
    '.skew-10deg': { transform: 'skewY(-10deg)' },
    '.skew-15deg': { transform: 'skewY(-15deg)' },
    '.content-auto': { contentVisibility: 'auto' },
  })
})

Generated classes participate in the full variant pipeline (hover:skew-10deg, md:skew-15deg, dark:content-auto).

Pattern 2 : Component Class (addComponents)

module.exports = plugin(function ({ addComponents, theme }) {
  addComponents({
    '.btn': {
      padding: '0.5rem 1rem',
      borderRadius: theme('borderRadius.md'),
      backgroundColor: theme('colors.zinc.900'),
      color: theme('colors.zinc.50'),
      '&:hover': {
        backgroundColor: theme('colors.zinc.700'),
      },
    },
  })
})

ALWAYS use addComponents (not addUtilities) for multi-utility recipes. Component classes are emitted into the components layer so user-applied utilities (btn rounded-none) override them.

Pattern 3 : Base Styles (addBase)

module.exports = plugin(function ({ addBase, theme }) {
  addBase({
    h1: { fontSize: theme('fontSize.2xl'), fontWeight: theme('fontWeight.bold') },
    h2: { fontSize: theme('fontSize.xl') },
    'html': { fontFamily: theme('fontFamily.sans').join(', ') },
  })
})

NEVER expect variants on addBase output. Base rules target raw selectors, not class tokens ; hover:h1 is meaningless.

Pattern 4 : Static Variant (addVariant)

module.exports = plugin(function ({ addVariant }) {
  addVariant('hocus', ['&:hover', '&:focus'])
  addVariant('optional', '&:optional')
  addVariant('group-hocus', [':merge(.group):hover &', ':merge(.group):focus &'])
})

Usage : class="hocus:underline" underlines on hover OR focus.

Pattern 5 : Functional Utility (matchUtilities)

module.exports = plugin(function ({ matchUtilities, theme }) {
  matchUtilities(
    {
      tab: (value) => ({ tabSize: value }),
    },
    {
      values: theme('tabSize'),
      type: ['number', 'length'],
      supportsNegativeValues: false,
    }
  )
})

values is the map of { classSuffix: value }. Reading from theme('tabSize') automatically picks up user overrides in tailwind.config.js theme.extend.tabSize. Without a values map, matchUtilities generates ZERO classes (silent failure).

Arbitrary-value support is automatic : tab-[7] works without listing 7 in values.

Pattern 6 : Parameterised Variant (matchVariant)

module.exports = plugin(function ({ matchVariant }) {
  matchVariant(
    'nth',
    (value) => `&:nth-child(${value})`,
    { values: { 1: '1', 2: '2', odd: 'odd', even: 'even' } }
  )
})

Usage : nth-1:underline, nth-[3n+1]:bg-zinc-50.

Pattern 7 : Plugin With User Options

const plugin = require('tailwindcss/plugin')

module.exports = plugin.withOptions(
  function (options = {}) {
    const className = options.className ?? 'markdown'
    return function ({ addComponents, theme }) {
      addComponents({
        [`.${className}`]: {
          maxWidth: theme('maxWidth.prose'),
          color: theme('colors.zinc.700'),
        },
      })
    }
  },
  function (options = {}) {
    return {
      theme: {
        extend: {
          maxWidth: {
            prose: options.proseWidth ?? '65ch',
          },
        },
      },
    }
  }
)

User invokes with require('./my-plugin')({ className: 'docs', proseWidth: '72ch' }).

Pattern 8 : Shipping Default Theme Values

plugin() accepts a second argument with theme.extend defaults :

module.exports = plugin(
  function ({ matchUtilities, theme }) {
    matchUtilities(
      { tab: (v) => ({ tabSize: v }) },
      { values: theme('tabSize') }
    )
  },
  {
    theme: {
      tabSize: { 1: '1', 2: '2', 4: '4', 8: '8' },
    },
  }
)

User-side theme.extend.tabSize overrides merge over the plugin defaults.

v4-Native : @utility Directive (CSS Authoring)

When you only target v4 and prefer CSS over JS :

@utility content-auto {
  content-visibility: auto;
}

@utility skew-10deg {
  transform: skewY(-10deg);
}

Functional form with --value() reads tokens from @theme :

@theme {
  --tab-size-1: 1;
  --tab-size-2: 2;
  --tab-size-4: 4;
}

@utility tab-* {
  tab-size: --value(--tab-size-*);
}

tab-* automatically supports bare integers (tab-3), arbitrary values (tab-[7]), and theme keys (tab-2). See tailwind-syntax-functional-utilities for --modifier(), --alpha(), --spacing().

v4-Native : @custom-variant Directive

@custom-variant theme-midnight (&:where([data-theme="midnight"] *));

@custom-variant any-hover {
  @media (any-hover: hover) {
    &:hover {
      @slot;
    }
  }
}

The shorthand form (parenthesised) is single-selector. The block form supports media queries and nested rules via @slot.

v4 : Loading a JS Plugin via @plugin

A v4 project can still load legacy JS plugins :

@import "tailwindcss";

@plugin "@tailwindcss/typography";
@plugin "./plugins/my-local-plugin.js";

The path is resolved relative to the CSS file. The plugin file uses the standard plugin() signature ; the v4 build loads it through the same JS bridge that maintains v3 compatibility.

Theme Path Syntax

The theme() helper accepts dot-notation paths into the resolved theme object :

theme('colors.red.500')         // '#ef4444'
theme('spacing.4')              // '1rem'
theme('fontSize.lg')            // '1.125rem' or [size, { lineHeight }]
theme('screens.md')             // '768px'
theme('fontFamily.sans')        // array of family names
theme('borderRadius.full')      // '9999px'

Paths trace theme after merging user theme.extend. Missing paths return undefined, NOT an error : guard with ?? fallback.

Packaging a Plugin as an npm Module

package.json :

{
  "name": "@my-org/tailwind-skew",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "exports": {
    ".": "./dist/index.js"
  },
  "peerDependencies": {
    "tailwindcss": ">=3.4 <5"
  },
  "files": ["dist"]
}

src/index.js :

const plugin = require('tailwindcss/plugin')

module.exports = plugin(function ({ addUtilities }) {
  addUtilities({
    '.skew-10deg': { transform: 'skewY(-10deg)' },
  })
})

User-side install :

npm install @my-org/tailwind-skew

v3 user :

// tailwind.config.js
module.exports = {
  plugins: [require('@my-org/tailwind-skew')],
}

v4 user :

@import "tailwindcss";
@plugin "@my-org/tailwind-skew";

Use peerDependencies for tailwindcss, NOT dependencies. A direct dependency would install a duplicate Tailwind in user node_modules and break version resolution.

Verification Checklist

  1. Plugin classes appear in compiled CSS. Grep .next/static/css or the Vite-emitted CSS file for one of your selectors.
  2. Variants apply : hover:my-utility produces a :hover rule, not a raw rule. If not, you probably registered through addBase.
  3. matchUtilities generates classes for every key in values. Count selectors in the output bundle.
  4. Arbitrary values work : my-util-[42] produces a CSS rule.
  5. Theme lookups resolve : log theme('colors.brand') from inside the plugin body during development.
  6. v4 @plugin loading : the plugin file path resolves relative to the CSS file, NOT the project root.

References

  • references/methods.md : every helper signature, options, v3-only vs v3+v4 vs v4-only authoring matrix
  • references/examples.md : ten full plugin source files with matching consumer-side usage
  • references/anti-patterns.md : eight authorship traps with cause, symptom, fix

Sources