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 > *.
| Selector | Result |
|---|---|
&:hover | .class:hover |
& > * | .class > * |
& + & | .class + .class |
.group:hover & | .group:hover .class |
[data-state="open"] & | [data-state="open"] .class |
The addVariant table
| Form | Example |
|---|---|
| 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
| Option | Purpose |
|---|---|
values | The allowed values |
sort | The variant order |
modifiers | The 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
| Pattern | Selector |
|---|---|
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
| Form | Example |
|---|---|
| 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
| Option | Purpose |
|---|---|
values | The allowed values |
sort | The variant order |
modifiers | The second value |
The Common Variants
| Variant | Selector |
|---|---|
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
| Selector | Result |
|---|---|
&:hover | .class:hover |
& > * | .class > * |
& + & | .class + .class |
.group:hover & | .group:hover .class |
[data-state="open"] & | [data-state="open"] .class |
The At-Rules
| At-rule | Example |
|---|---|
@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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The variant does not apply | The & is missing | Add the placeholder |
| The selector is wrong | The wrong at-rule | Check the feature |
| The order is wrong | The sort is missing | Add the sort |
| The wrapper duplicates | The :merge is missing | Use the marker |
| The container query fails | No container declared | Add the @container |
| The name collision | The core variant | Use the unique name |
| The attribute is wrong | The value mismatch | Check 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
| Item | Value |
|---|---|
addVariant | The static variant |
matchVariant | The dynamic variant |
& | The placeholder for the utility’s class |
| The list | The multiple selectors |
@supports | The feature query |
@container | The container query |
:merge(.group) | The wrapper marker |
sort | The variant order |
| The attribute | The data and the aria |
| The composition | The stacked variants |
Key takeaways:
- The
addVariantregisters 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
matchVariantregisters the dynamic variant. The function produces the selector from the value. Thevaluesoption supplies the allowed values, and thesortcontrols the order. - The
&placeholder is the utility’s class. The&:hoverproduces 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 coregroup-hover:. - The at-rules register the media and the feature queries. The
@media, the@supports, and the@containerare the examples. The container query requires the container context. - The
sortorders the dynamic variants. The order matters when the variants are the same property and the cascade must be predictable. TheparseIntcomparison 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-500is 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!