| |

Tailwind CSS 69 🎨 Custom Variant Function Plugins (Selector Manipulation and Media Queries)

A variant is the prefix that modifies a utility. The hover: applies the utility on hover, the md: applies it at the medium breakpoint, and the dark: applies it in the dark theme. Tailwind ships the common variants, and the addVariant and the matchVariant functions are the mechanisms for the custom ones. The variant is a selector manipulation: the &:hover appends the pseudo-class, the & > * targets the children, and the @media (min-width: 768px) wraps the rule. This chapter covers the two functions, the selector manipulation patterns, the media query patterns, and the composition that makes the variants a small language.

Key point: The addVariant takes a static selector or a list of the selectors. The matchVariant takes a function that produces the selector from the value. The & is the placeholder for the utility’s class, and the selector replaces it.


Why custom variant plugins exist

The pseudo-class problem. The CSS has hundreds of pseudo-classes. The :has(), the :optional, the :target, and the :focus-within are the modern ones. The core variants cover the common ones, and the addVariant covers the rest.

The attribute problem. The [data-state="open"] and the [aria-expanded="true"] are the attribute selectors. The component libraries use the data attributes, and the addVariant registers the attribute-based variant.

The wrapper problem. The utility applies when the ancestor has the class. The .group and the .peer are the core variants, and the addVariant registers the custom wrappers.

The media query problem. The @supports, the @container, and the @media (prefers-contrast) are the modern queries. The addVariant registers the @supports, and the matchVariant registers the dynamic @container.

The dynamic value problem. The nth-child and the nth-of-type take the value. The matchVariant generates the variant from the value, and the nth-[4n+1]: is the example.

The child problem. The & > * targets the direct children, and the & + & targets the adjacent siblings. The addVariant registers the child-based variant.

The composition problem. The variants stack. The dark:md:hover: is the composition of the three. The custom variants compose with the core ones, and the order is the order in the class name.


a. The addVariant

The addVariant takes the name and the selector. The selector is the string, the list, or the at-rule.

The basic pseudo-class

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

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

The optional:border-red-500 applies the border-red-500 when the input is optional. The hocus: applies on hover or focus.

The at-rule

addVariant('supports-grid', '@supports (display: grid)');
addVariant('contrast-more', '@media (prefers-contrast: more)');

The supports-grid:grid applies the display: grid when the browser supports the grid.

The list of the selectors

addVariant('hocus', ['&:hover', '&:focus']);

The list produces the multiple selectors from the one variant. The hocus:bg-blue-500 produces the .hocus\:bg-blue-500:hover, .hocus\:bg-blue-500:focus.

The attribute selector

addVariant('aria-expanded', '&[aria-expanded="true"]');
addVariant('data-open', '&[data-state="open"]');

The aria-expanded:bg-blue-500 applies when the element has the aria-expanded="true".

The child selector

addVariant('children', '& > *');
addVariant('siblings', '& + &');

The children:p-4 applies the p-4 to the direct children.

The wrapper selector

addVariant('group-hocus', [':merge(.group):hover &', ':merge(.group):focus &']);

The :merge(.group) is the marker that Tailwind uses to merge the multiple selectors. The pattern is the same as the core group-hover:.

The selector list

addVariant('hocus', ['&:hover', '&:focus', '&:focus-visible']);

The list produces the three selectors from the one variant.

The & placeholder

The & is the placeholder for the utility’s class. The &:hover produces the .class:hover, and the & > * produces the .class > *.

SelectorResult
&:hover.class:hover
& > *.class > *
& + &.class + .class
.group:hover &.group:hover .class
[data-state="open"] &[data-state="open"] .class

The addVariant table

FormExample
The pseudo-class'&:optional'
The list['&:hover', '&:focus']
The at-rule'@supports (display: grid)'
The attribute'&[aria-expanded="true"]'
The child'& > *'
The wrapper':merge(.group):hover &'

b. The matchVariant

The matchVariant takes the name, the function, and the options. The function produces the selector from the value.

The basic matchVariant

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

The nth-2:bg-blue-500 produces the &:nth-child(2).

The arbitrary value

matchVariant('nth', (value) => `&:nth-child(${value})`);

The nth-[4n+1]:bg-blue-500 produces the &:nth-child(4n+1). The arbitrary value works without the values option.

The container query

matchVariant(
  '@',
  (value) => `@container (min-width: ${value})`,
  {
    values: {
      sm: '24rem',
      md: '28rem',
      lg: '32rem',
    },
    sort: (a, b) => parseInt(a) - parseInt(b),
  }
);

The @md:flex produces the @container (min-width: 28rem). The sort orders the variants.

The sort function

sort: (a, b) => parseInt(a) - parseInt(b)

The sort orders the generated variants. The order matters when the variants are the same property and the cascade must be predictable.

The modifiers

matchVariant(
  'nth',
  (value, { modifier }) => `&:nth-child(${value} of ${modifier})`,
  {
    values: { 1: '1', 2: '2' },
    modifiers: 'any',
  }
);

The nth-2/li: form passes the 2 as the value and the li as the modifier.

The matchVariant table

OptionPurpose
valuesThe allowed values
sortThe variant order
modifiersThe second value

c. The common variant patterns

The patterns below are the ones that appear in the real projects.

The has() variant

addVariant('has-checked', '&:has(:checked)');
addVariant('has-focus', '&:has(:focus)');

The has-checked:bg-blue-500 applies when the element contains a checked input.

The not() variant

addVariant('not-first', '&:not(:first-child)');
addVariant('not-last', '&:not(:last-child)');

The not-first:mt-4 applies the margin to every child except the first.

The nth-child variant

matchVariant(
  'nth',
  (value) => `&:nth-child(${value})`,
  {
    values: { 1: '1', 2: '2', 3: '3', odd: 'odd', even: 'even' },
    sort: (a, b) => parseInt(a) - parseInt(b),
  }
);

The nth-odd:bg-slate-100 applies the background to the odd rows.

The @supports variant

addVariant('supports-grid', '@supports (display: grid)');
addVariant('supports-backdrop', '@supports (backdrop-filter: blur(1px))');

The supports-grid:grid applies the grid when the browser supports it.

The @container variant

matchVariant(
  '@',
  (value) => `@container (min-width: ${value})`,
  {
    values: { sm: '24rem', md: '28rem', lg: '32rem' },
    sort: (a, b) => parseInt(a) - parseInt(b),
  }
);

The @md:flex applies the flex when the container is at least the 28rem.

The prefers-color-scheme variant

addVariant('scheme-dark', '@media (prefers-color-scheme: dark)');
addVariant('scheme-light', '@media (prefers-color-scheme: light)');

The scheme-dark:bg-slate-900 applies the dark background when the OS prefers the dark.

The prefers-reduced-motion variant

addVariant('motion-safe', '@media (prefers-reduced-motion: no-preference)');
addVariant('motion-reduce', '@media (prefers-reduced-motion: reduce)');

The motion-reduce:animate-none disables the animation.

The aria variant

addVariant('aria-expanded', '&[aria-expanded="true"]');
addVariant('aria-current', '&[aria-current="page"]');

The aria-expanded:bg-blue-500 applies when the aria-expanded is true.

The data variant

addVariant('data-open', '&[data-state="open"]');
addVariant('data-closed', '&[data-state="closed"]');

The data-open:block applies when the data-state is open.

The rtl and the ltr variants

addVariant('rtl', '&:where([dir="rtl"], [dir="rtl"] *)');
addVariant('ltr', '&:where([dir="ltr"], [dir="ltr"] *)');

The rtl:ml-4 applies the margin on the right in the RTL direction.

The pattern table

PatternSelector
has()&:has(:checked)
not()&:not(:first-child)
nth-child&:nth-child(odd)
@supports@supports (display: grid)
@container@container (min-width: 28rem)
prefers-color-scheme@media (prefers-color-scheme: dark)
prefers-reduced-motion@media (prefers-reduced-motion: reduce)
aria&[aria-expanded="true"]
data&[data-state="open"]
rtl&:where([dir="rtl"], [dir="rtl"] *)

Complete Example Session

// ============================================
// PART 1: THE BASIC ADD-VARIANT
// ============================================

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

module.exports = plugin(({ addVariant }) => {
  addVariant('optional', '&:optional');
  addVariant('hocus', ['&:hover', '&:focus']);
});

// <input class="optional:border-red-500" />
// <button class="hocus:bg-blue-500">Click</button>

// ============================================
// PART 2: THE AT-RULE
// ============================================

addVariant('supports-grid', '@supports (display: grid)');
addVariant('contrast-more', '@media (prefers-contrast: more)');

// <div class="supports-grid:grid">...</div>

// ============================================
// PART 3: THE ATTRIBUTE SELECTOR
// ============================================

addVariant('aria-expanded', '&[aria-expanded="true"]');
addVariant('data-open', '&[data-state="open"]');

// <button class="aria-expanded:bg-blue-500">Toggle</button>

// ============================================
// PART 4: THE CHILD SELECTOR
// ============================================

addVariant('children', '& > *');
addVariant('siblings', '& + &');

// <ul class="children:p-4">...</ul>

// ============================================
// PART 5: THE WRAPPER
// ============================================

addVariant('group-hocus', [
  ':merge(.group):hover &',
  ':merge(.group):focus &',
]);

// <div class="group">
//   <button class="group-hocus:bg-blue-500">Click</button>
// </div>

// ============================================
// PART 6: THE MATCH-VARIANT
// ============================================

module.exports = plugin(({ matchVariant }) => {
  matchVariant(
    'nth',
    (value) => `&:nth-child(${value})`,
    {
      values: { 1: '1', 2: '2', 3: '3', odd: 'odd', even: 'even' },
      sort: (a, b) => parseInt(a) - parseInt(b),
    }
  );
});

// <tr class="nth-odd:bg-slate-100">...</tr>

// ============================================
// PART 7: THE CONTAINER QUERY
// ============================================

matchVariant(
  '@',
  (value) => `@container (min-width: ${value})`,
  {
    values: { sm: '24rem', md: '28rem', lg: '32rem' },
    sort: (a, b) => parseInt(a) - parseInt(b),
  }
);

// <div class="@md:flex">...</div>

// ============================================
// PART 8: THE PREFERS-COLOR-SCHEME
// ============================================

addVariant('scheme-dark', '@media (prefers-color-scheme: dark)');
addVariant('scheme-light', '@media (prefers-color-scheme: light)');

// <div class="scheme-dark:bg-slate-900">...</div>

// ============================================
// PART 9: THE REDUCED MOTION
// ============================================

addVariant('motion-safe', '@media (prefers-reduced-motion: no-preference)');
addVariant('motion-reduce', '@media (prefers-reduced-motion: reduce)');

// <div class="motion-reduce:animate-none">...</div>

// ============================================
// PART 10: THE COMPLETE PATTERN
// ============================================

// 1. Use the addVariant for the static variants.
// 2. Use the matchVariant for the dynamic variants.
// 3. Use the & placeholder for the utility's class.
// 4. Use the list for the multiple selectors.
// 5. Use the @-rule for the media queries.
// 6. Use the :merge(.group) for the wrapper.
// 7. Use the sort for the variant order.
// 8. Use the modifiers for the second value.
// 9. Compose the custom variants with the core ones.
// 10. Test the generated CSS.

The ten parts showed the basic addVariant, the at-rule, the attribute selector, the child selector, the wrapper, the matchVariant, the container query, the prefers-color-scheme, the reduced motion, and the complete pattern.


Quick Reference

The addVariant Forms

FormExample
The pseudo-class'&:optional'
The list['&:hover', '&:focus']
The at-rule'@supports (display: grid)'
The attribute'&[aria-expanded="true"]'
The child'& > *'
The wrapper':merge(.group):hover &'

The matchVariant Options

OptionPurpose
valuesThe allowed values
sortThe variant order
modifiersThe second value

The Common Variants

VariantSelector
has-checked&:has(:checked)
not-first&:not(:first-child)
nth-odd&:nth-child(odd)
@md@container (min-width: 28rem)
scheme-dark@media (prefers-color-scheme: dark)
motion-reduce@media (prefers-reduced-motion: reduce)
aria-expanded&[aria-expanded="true"]
data-open&[data-state="open"]
rtl&:where([dir="rtl"], [dir="rtl"] *)

The & Placeholder

SelectorResult
&:hover.class:hover
& > *.class > *
& + &.class + .class
.group:hover &.group:hover .class
[data-state="open"] &[data-state="open"] .class

The At-Rules

At-ruleExample
@media@media (min-width: 768px)
@supports@supports (display: grid)
@container@container (min-width: 28rem)

Best Practices

✅ Do This:

// Use the addVariant for the static variants.
addVariant('optional', '&:optional');  // ✅

// Use the list for the multiple selectors.
addVariant('hocus', ['&:hover', '&:focus']);  // ✅

// Use the at-rule for the media queries.
addVariant('supports-grid', '@supports (display: grid)');  // ✅

// Use the attribute selector for the data attributes.
addVariant('data-open', '&[data-state="open"]');  // ✅

// Use the :merge(.group) for the wrapper.
addVariant('group-hocus', [':merge(.group):hover &', ':merge(.group):focus &']);  // ✅

// Use the matchVariant for the dynamic values.
matchVariant('nth', (v) => `&:nth-child(${v})`, { values: { ... } });  // ✅

// Use the sort for the variant order.
sort: (a, b) => parseInt(a) - parseInt(b);  // ✅

// Use the @container for the container queries.
matchVariant('@', (v) => `@container (min-width: ${v})`, { values: { ... } });  // ✅

❌ Don’t Do This:

// Don't forget the & placeholder.
addVariant('optional', ':optional');  // ❌ The selector is wrong

// Don't use the matchVariant for the static variant.
matchVariant('hocus', () => '&:hover');  // ⚠️ Use the addVariant

// Don't forget the sort when the order matters.
matchVariant('nth', (v) => `&:nth-child(${v})`, { values: { ... } });  // ⚠️ The arbitrary order

// Don't use the wrong at-rule.
addVariant('grid', '@supports (display: flex)');  // ❌ The wrong feature

// Don't skip the merge for the wrapper.
addVariant('group-hocus', ['.group:hover &', '.group:focus &']);  // ⚠️ The duplication

// Don't forget the @container context.
matchVariant('@', (v) => `@container (min-width: ${v})`);  // ⚠️ The container must be declared

// Don't use the same name for the core variant.
addVariant('hover', '&:hover');  // ❌ The core has it

Common Pitfalls

PitfallWhy It HappensFix
The variant does not applyThe & is missingAdd the placeholder
The selector is wrongThe wrong at-ruleCheck the feature
The order is wrongThe sort is missingAdd the sort
The wrapper duplicatesThe :merge is missingUse the marker
The container query failsNo container declaredAdd the @container
The name collisionThe core variantUse the unique name
The attribute is wrongThe value mismatchCheck the attribute

Real-World Examples

1. The Basic Variant

addVariant('optional', '&:optional'),

2. The List

addVariant('hocus', ['&:hover', '&:focus']),

3. The At-Rule

addVariant('supports-grid', '@supports (display: grid)'),

4. The Attribute

addVariant('data-open', '&[data-state="open"]'),

5. The Child

addVariant('children', '& > *'),

6. The Wrapper

addVariant('group-hocus', [':merge(.group):hover &', ':merge(.group):focus &']),

7. The nth-child

matchVariant('nth', (v) => `&:nth-child(${v})`, { values: { odd: 'odd' } }),

8. The Container Query

matchVariant('@', (v) => `@container (min-width: ${v})`, { values: { md: '28rem' } }),

9. The Prefers Color Scheme

addVariant('scheme-dark', '@media (prefers-color-scheme: dark)'),

10. The Reduced Motion

addVariant('motion-reduce', '@media (prefers-reduced-motion: reduce)'),

Visual

The Variant Composition

┌────────────────────────────────────────────────────────────┐
│  THE VARIANT COMPOSITION                                   │
│                                                            │
│  <div class="dark:md:hover:bg-blue-500">                   │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  The variants stack:                              │     │
│  │    dark → .dark .class                            │     │
│  │    md   → @media (min-width: 768px)               │     │
│  │    hover → &:hover                                │     │
│  │                                                   │     │
│  │  The generated CSS:                               │     │
│  │    @media (min-width: 768px) {                    │     │
│  │      .dark .dark\:md\:hover\:bg-blue-500:hover {  │     │
│  │        background-color: ...;                     │     │
│  │      }                                            │     │
│  │    }                                              │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The order in the class name is the order of the nesting.  │
└────────────────────────────────────────────────────────────┘

The & Placeholder

┌────────────────────────────────────────────────────────────┐
│  THE & PLACEHOLDER                                         │
│                                                            │
│  The utility class: .bg-blue-500                           │
│                                                            │
│  The variant: '&:hover'                                    │
│  ┌──────────────────────────────────────────────────┐     │
│  │  The & is replaced by the class:                   │     │
│  │    .bg-blue-500:hover                              │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The variant: '& > *'                                      │
│  ┌──────────────────────────────────────────────────┐     │
│  │  The & is replaced by the class:                   │     │
│  │    .bg-blue-500 > *                                │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The variant: '.group:hover &'                             │
│  ┌──────────────────────────────────────────────────┐     │
│  │  The & is replaced by the class:                   │     │
│  │    .group:hover .bg-blue-500                       │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

The matchVariant

┌────────────────────────────────────────────────────────────┐
│  THE matchVariant                                          │
│                                                            │
│  matchVariant(                                             │
│    'nth',                                                  │
│    (value) => `&:nth-child(${value})`,                     │
│    { values: { 1: '1', 2: '2', odd: 'odd' } }              │
│  );                                                        │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  nth-1:bg-blue-500   →  &:nth-child(1)            │     │
│  │  nth-odd:bg-blue-500 →  &:nth-child(odd)          │     │
│  │  nth-[4n+1]:...      →  &:nth-child(4n+1)         │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The function produces the selector from the value.        │
└────────────────────────────────────────────────────────────┘

The Wrapper

┌────────────────────────────────────────────────────────────┐
│  THE WRAPPER                                               │
│                                                            │
│  addVariant('group-hocus', [                               │
│    ':merge(.group):hover &',                               │
│    ':merge(.group):focus &',                               │
│  ]);                                                       │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  <div class="group">                              │     │
│  │    <button class="group-hocus:bg-blue-500">       │     │
│  │  </div>                                           │     │
│  │                                                   │     │
│  │  The generated CSS:                               │     │
│  │    .group:hover .group-hocus\:bg-blue-500,        │     │
│  │    .group:focus .group-hocus\:bg-blue-500 {       │     │
│  │      background-color: ...;                       │     │
│  │    }                                              │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

Summary

ItemValue
addVariantThe static variant
matchVariantThe dynamic variant
&The placeholder for the utility’s class
The listThe multiple selectors
@supportsThe feature query
@containerThe container query
:merge(.group)The wrapper marker
sortThe variant order
The attributeThe data and the aria
The compositionThe stacked variants

Key takeaways:

  • The addVariant registers the static variant. The selector is the string, the list, or the at-rule. The & is the placeholder for the utility’s class, and the selector replaces it.
  • The matchVariant registers the dynamic variant. The function produces the selector from the value. The values option supplies the allowed values, and the sort controls the order.
  • The & placeholder is the utility’s class. The &:hover produces the .class:hover, and the & > * produces the .class > *. The wrapper form is the .group:hover &.
  • The :merge(.group) is the wrapper marker. The marker merges the multiple selectors into the one rule. The pattern is the same as the core group-hover:.
  • The at-rules register the media and the feature queries. The @media, the @supports, and the @container are the examples. The container query requires the container context.
  • The sort orders the dynamic variants. The order matters when the variants are the same property and the cascade must be predictable. The parseInt comparison is the common sort.
  • The variants compose. The dark:md:hover: is the composition of the three. The order in the class name is the order of the nesting.
  • The custom variants compose with the core ones. The group-hocus:md:bg-blue-500 is the example. The custom variant is the additional selector in the chain.

Remember: The custom variant plugins extend the Tailwind engine with the selectors and the media queries. The addVariant registers the static variant, and the matchVariant registers the dynamic one. The & is the placeholder, the :merge(.group) is the wrapper marker, and the sort orders the dynamic variants. The result is a framework where the design system’s states and the media conditions are the named variants, and the utilities compose with the custom and the core variants.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!