Skip to content
Codeloom
Tailwind

Tailwind CSS Plugins and Presets

Learn to write custom Tailwind plugins, use presets for shared configurations, and extend Tailwind's utility system with your own design primitives.

·7 min read · By Codeloom
Advanced 14 min read

What you'll learn

  • How Tailwind plugins work and what APIs they expose
  • How to write a custom utility plugin from scratch
  • How to add custom variants for new interactive states
  • How to create component plugins with base styles
  • How to package shared configurations as presets

Prerequisites

  • Solid understanding of Tailwind CSS utilities and config
  • Comfortable with JavaScript/Node.js

Tailwind’s plugin system is the mechanism behind everything: the official typography plugin, the forms plugin, and even Tailwind’s own core utilities are plugins internally. Writing your own is not hard once you understand the four plugin APIs. This article walks through each one and shows how to package reusable configurations as presets.

Plugin Architecture

A Tailwind plugin is a function that receives a helper object and uses it to register CSS with Tailwind’s build system. There are four main helpers:

addUtilities()    -> Register new utility classes (like bg-*, text-*)
addComponents()   -> Register component classes (like .btn, .card)
addBase()         -> Inject base/reset styles (like normalize)
addVariant()      -> Create new variants (like hover:, focus:, but custom)

matchUtilities() -> Register utilities with dynamic values (like bg-[#hex])
Plugin API surface

Writing Your First Plugin

Let’s create a plugin that adds text-shadow utilities.

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

export default {
  plugins: [
    plugin(function ({ addUtilities }) {
      addUtilities({
        '.text-shadow-sm': {
          'text-shadow': '0 1px 2px rgb(0 0 0 / 0.1)',
        },
        '.text-shadow': {
          'text-shadow': '0 2px 4px rgb(0 0 0 / 0.15)',
        },
        '.text-shadow-lg': {
          'text-shadow': '0 4px 8px rgb(0 0 0 / 0.2)',
        },
        '.text-shadow-none': {
          'text-shadow': 'none',
        },
      });
    }),
  ],
};

Now you can use these in your markup:

<h1 class="text-4xl font-bold text-shadow-lg">Hero Title</h1>
<p class="text-shadow-sm">Subtle shadow on paragraph text</p>

These utilities automatically work with Tailwind’s responsive and state prefixes:

<h1 class="text-shadow-sm md:text-shadow-lg hover:text-shadow-none">
  Responsive and interactive shadows
</h1>

Dynamic Utilities with matchUtilities

matchUtilities lets you create utilities that accept arbitrary values, just like bg-[#ff0] or p-[17px]:

const plugin = require('tailwindcss/plugin');

export default {
  theme: {
    extend: {
      textShadow: {
        sm: '0 1px 2px rgb(0 0 0 / 0.1)',
        DEFAULT: '0 2px 4px rgb(0 0 0 / 0.15)',
        lg: '0 4px 8px rgb(0 0 0 / 0.2)',
      },
    },
  },
  plugins: [
    plugin(function ({ matchUtilities, theme }) {
      matchUtilities(
        {
          'text-shadow': (value) => ({
            'text-shadow': value,
          }),
        },
        { values: theme('textShadow') }
      );
    }),
  ],
};

Now these all work:

<!-- Named values from config -->
<p class="text-shadow-sm">Small shadow</p>
<p class="text-shadow-lg">Large shadow</p>

<!-- Arbitrary values -->
<p class="text-shadow-[0_4px_12px_rgba(0,0,0,0.3)]">Custom shadow</p>

Adding Component Classes

Component classes sit in the @layer components layer, so utility classes can override them. Use addComponents for reusable UI patterns:

const plugin = require('tailwindcss/plugin');

export default {
  plugins: [
    plugin(function ({ addComponents, theme }) {
      addComponents({
        '.btn': {
          display: 'inline-flex',
          alignItems: 'center',
          justifyContent: 'center',
          paddingLeft: theme('spacing.4'),
          paddingRight: theme('spacing.4'),
          paddingTop: theme('spacing.2'),
          paddingBottom: theme('spacing.2'),
          fontSize: theme('fontSize.sm'),
          fontWeight: theme('fontWeight.semibold'),
          borderRadius: theme('borderRadius.md'),
          transitionProperty: 'background-color, border-color, color',
          transitionDuration: '150ms',
          '&:focus': {
            outline: 'none',
            boxShadow: `0 0 0 2px ${theme('colors.white')}, 0 0 0 4px ${theme('colors.blue.500')}`,
          },
        },
        '.btn-primary': {
          backgroundColor: theme('colors.blue.600'),
          color: theme('colors.white'),
          '&:hover': {
            backgroundColor: theme('colors.blue.700'),
          },
        },
        '.btn-secondary': {
          backgroundColor: theme('colors.gray.100'),
          color: theme('colors.gray.800'),
          '&:hover': {
            backgroundColor: theme('colors.gray.200'),
          },
        },
      });
    }),
  ],
};

Usage:

<button class="btn btn-primary">Save changes</button>
<button class="btn btn-secondary">Cancel</button>

<!-- Utility classes can override component styles -->
<button class="btn btn-primary rounded-full px-8">Custom shape</button>

Adding Base Styles

Base styles apply globally, like a CSS reset. Use addBase for typography defaults, link styles, or custom resets:

plugin(function ({ addBase, theme }) {
  addBase({
    'h1': {
      fontSize: theme('fontSize.3xl'),
      fontWeight: theme('fontWeight.bold'),
      lineHeight: theme('lineHeight.tight'),
    },
    'h2': {
      fontSize: theme('fontSize.2xl'),
      fontWeight: theme('fontWeight.semibold'),
      lineHeight: theme('lineHeight.tight'),
    },
    'a': {
      color: theme('colors.blue.600'),
      textDecoration: 'underline',
      '&:hover': {
        color: theme('colors.blue.800'),
      },
    },
  });
}),

Custom Variants

Variants are the prefixes like hover:, focus:, dark:. You can create your own:

plugin(function ({ addVariant }) {
  // Matches when a parent has the .sidebar-open class
  addVariant('sidebar-open', '.sidebar-open &');

  // Matches the element itself when it has a data attribute
  addVariant('data-active', '&[data-active="true"]');

  // Group variant for specific states
  addVariant('group-checked', ':merge(.group):checked &');

  // Media query variant
  addVariant('supports-grid', '@supports (display: grid)');
}),

Usage:

<!-- Hide element unless sidebar is open -->
<div class="hidden sidebar-open:block">
  Sidebar content
</div>

<!-- Style based on data attribute -->
<button class="bg-gray-200 data-active:bg-blue-600 data-active:text-white"
        data-active="true">
  Active Tab
</button>

<!-- Feature detection -->
<div class="flex supports-grid:grid supports-grid:grid-cols-3">
  Graceful degradation
</div>

Extracting Plugins to Separate Files

For larger plugins, move them to their own file:

// plugins/text-shadow.js
const plugin = require('tailwindcss/plugin');

module.exports = plugin(
  function ({ matchUtilities, theme }) {
    matchUtilities(
      {
        'text-shadow': (value) => ({
          'text-shadow': value,
        }),
      },
      { values: theme('textShadow') }
    );
  },
  {
    // Default theme values shipped with the plugin
    theme: {
      textShadow: {
        sm: '0 1px 2px rgb(0 0 0 / 0.1)',
        DEFAULT: '0 2px 4px rgb(0 0 0 / 0.15)',
        lg: '0 4px 8px rgb(0 0 0 / 0.2)',
        xl: '0 8px 16px rgb(0 0 0 / 0.25)',
      },
    },
  }
);

The second argument to plugin() lets you ship default theme values. Users can override them in their own config:

// tailwind.config.js
export default {
  theme: {
    extend: {
      textShadow: {
        subtle: '0 0.5px 1px rgb(0 0 0 / 0.05)',
      },
    },
  },
  plugins: [
    require('./plugins/text-shadow'),
  ],
};

Presets: Sharing Full Configurations

A preset is a complete Tailwind config that another project can inherit from. This is how design systems share tokens, plugins, and settings across multiple apps.

// presets/acme-design-system.js
module.exports = {
  theme: {
    colors: {
      transparent: 'transparent',
      current: 'currentColor',
      white: '#ffffff',
      black: '#000000',
      brand: {
        50: '#eff6ff',
        100: '#dbeafe',
        500: '#3b82f6',
        600: '#2563eb',
        700: '#1d4ed8',
        900: '#1e3a5f',
      },
      neutral: {
        50: '#fafafa',
        100: '#f5f5f5',
        200: '#e5e5e5',
        500: '#737373',
        700: '#404040',
        900: '#171717',
      },
      success: { 500: '#22c55e' },
      warning: { 500: '#f59e0b' },
      error: { 500: '#ef4444' },
    },
    fontFamily: {
      sans: ['Inter', 'system-ui', 'sans-serif'],
      mono: ['JetBrains Mono', 'monospace'],
    },
    borderRadius: {
      none: '0',
      sm: '0.25rem',
      DEFAULT: '0.5rem',
      lg: '0.75rem',
      full: '9999px',
    },
  },
  plugins: [
    require('@tailwindcss/forms'),
    require('@tailwindcss/typography'),
  ],
};

A consuming project uses it with presets:

// tailwind.config.js (in the app)
export default {
  presets: [
    require('./presets/acme-design-system'),
  ],
  content: ['./src/**/*.{js,jsx,ts,tsx}'],
  theme: {
    extend: {
      // App-specific overrides layer on top
      colors: {
        accent: '#8b5cf6',
      },
    },
  },
};

How Presets Merge

Preset theme values  +  Project theme.extend  =  Final config

For theme keys:
- theme.extend.* merges (adds to preset values)
- theme.* replaces (overrides preset values entirely)

For plugins:
- Preset plugins + project plugins are combined

For content:
- Project content is used (not inherited from preset)
Preset merge behavior

If you set theme.colors in your project (not theme.extend.colors), it completely replaces the preset colors. Use extend to add to a preset’s values.

Publishing a Preset as an npm Package

{
  "name": "@acme/tailwind-preset",
  "version": "1.0.0",
  "main": "preset.js",
  "peerDependencies": {
    "tailwindcss": ">=3.4"
  },
  "dependencies": {
    "@tailwindcss/forms": "^0.5",
    "@tailwindcss/typography": "^0.5"
  }
}

Consumers install it and reference it:

presets: [require('@acme/tailwind-preset')],

Official Plugins Worth Knowing

PluginWhat it does
@tailwindcss/formsResets form elements for clean styling
@tailwindcss/typographyAdds prose classes for rich text content
@tailwindcss/aspect-ratioAspect ratio utilities (mostly replaced by native CSS)
@tailwindcss/container-queries@container and @ prefix utilities

When to Write a Plugin vs Use @apply

Use a plugin when:

  • Multiple projects need the same utilities
  • You want integration with the Tailwind theme system
  • You need custom variants
  • You are building a design system package

Use @apply when:

  • You need a one-off component class in a single project
  • The pattern is simple and unlikely to change

In most cases, extracting a framework component (a React component, an Astro component) is better than either option. Keep CSS in utilities and keep reusability in components.

Wrap-Up

Tailwind plugins give you four APIs: addUtilities for single-purpose classes, addComponents for multi-property patterns, addBase for global resets, and addVariant for new conditional prefixes. Use matchUtilities for arbitrary value support. Package shared design tokens and plugin sets into presets so multiple projects stay consistent. The plugin system is what makes Tailwind extensible without forking the framework.