React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

tailwindv4custom-utilitiesutilityvariantplugins

Custom Utilities

Create custom utilities with @utility, custom variants with @variant, and use plugins in Tailwind CSS v4.

Recipe

Quick-reference recipe card — copy-paste ready.

/* globals.css */
@import "tailwindcss";
 
/* Custom utility — generates a single class */
@utility container-prose {
  max-width: 65ch;
  margin-inline: auto;
  padding-inline: 1.5rem;
}
 
/* Custom utility with responsive/hover support (automatic) */
@utility text-balance {
  text-wrap: balance;
}
 
/* Custom variant */
@variant hocus (&:hover, &:focus-visible);
@variant scrolled (&:where([data-scrolled]));
 
/* Using a v3-style plugin via @config */
@config "./tailwind.config.js";
// Usage
<div className="container-prose">
  <h1 className="text-balance hocus:text-blue-600">Custom Utilities</h1>
</div>
 
<header data-scrolled className="scrolled:bg-white scrolled:shadow">

When to reach for this: When Tailwind's built-in utilities do not cover a specific pattern you use repeatedly — create a custom utility once and use it everywhere.

Working Example

/* globals.css */
@import "tailwindcss";
 
/* Layout utilities */
@utility stack {
  display: flex;
  flex-direction: column;
}
 
@utility center {
  display: flex;
  align-items: center;
  justify-content: center;
}
 
@utility container-narrow {
  max-width: 42rem;
  margin-inline: auto;
  padding-inline: 1rem;
}
 
/* Visual utilities */
@utility glass {
  background: rgb(255 255 255 / 0.8);
  backdrop-filter: blur(12px);
  border: 1px solid rgb(255 255 255 / 0.2);
}
 
@utility gradient-text {
  background: linear-gradient(to right, var(--color-primary), var(--color-secondary, #8b5cf6));
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
}
 
@utility scrollbar-hidden {
  scrollbar-width: none;
  -ms-overflow-style: none;
  &::-webkit-scrollbar {
    display: none;
  }
}
 
/* Interactive variants */
@variant hocus (&:hover, &:focus-visible);
@variant group-hocus (:merge(.group):hover &, :merge(.group):focus-visible &);
@variant aria-current (&[aria-current="page"]);
@variant keyboard-focus (&:focus-visible);
 
/* State variants */
@variant loading (&[data-loading]);
@variant empty-state (&:where(:empty, [data-empty]));
 
@theme {
  --color-primary: #2563eb;
}
export function CustomUtilitiesDemo() {
  return (
    <div className="container-narrow stack gap-8 py-12">
      <h1 className="gradient-text text-4xl font-bold">
        Custom Utilities in Action
      </h1>
 
      <nav className="glass sticky top-0 z-10 rounded-xl p-4">
        <ul className="flex gap-4">
          <li>
            <a href="#" className="rounded px-3 py-1 hocus:bg-gray-100 aria-current:font-bold" aria-current="page">
              Home
            </a>
          </li>
          <li>
            <a href="#" className="rounded px-3 py-1 hocus:bg-gray-100">About</a>
          </li>
          <li>
            <a href="#" className="rounded px-3 py-1 hocus:bg-gray-100">Contact</a>
          </li>
        </ul>
      </nav>
 
      <div className="scrollbar-hidden flex gap-4 overflow-x-auto pb-2">
        {Array.from({ length: 10 }, (_, i) => (
          <div key={i} className="shrink-0 center size-24 rounded-lg bg-gray-100 text-sm font-medium">
            Card {i + 1}
          </div>
        ))}
      </div>
 
      <button
        data-loading
        className="rounded bg-blue-600 px-4 py-2 text-white loading:opacity-50 loading:cursor-wait"
      >
        Submit
      </button>
    </div>
  );
}

What this demonstrates:

  • @utility for reusable composite utility classes
  • @variant for custom state/pseudo selectors
  • Glass morphism effect as a utility
  • Gradient text utility using theme variables
  • Custom aria-current, data-loading variants
  • All custom utilities support responsive/hover modifiers automatically

Deep Dive

How It Works

  • @utility name { ... } creates a utility class .name in the utilities layer
  • Custom utilities automatically work with responsive (md:name), state (hover:name), and other modifiers
  • @variant name (selector) creates a modifier that applies when the selector matches
  • Multiple selectors in a variant are separated by commas: @variant hocus (&:hover, &:focus-visible)
  • The & in variant selectors represents the element the variant is applied to
  • Custom utilities in v4 replace most use cases for @layer components in v3

Variations

Functional utility (with values) via @theme:

/* You cannot create arbitrary-value custom utilities with @utility.
   Instead, define theme values and use built-in utilities. */
@theme {
  --spacing-page: 2rem;
  --spacing-section: 4rem;
  --width-content: 65ch;
  --width-wide: 90rem;
}
 
/* Now use: p-page, gap-section, max-w-content, max-w-wide */

Using v3 plugins:

// tailwind.config.js (only needed for plugins)
export default {
  plugins: [
    require("@tailwindcss/typography"),
    require("@tailwindcss/forms"),
  ],
};
/* globals.css */
@import "tailwindcss";
@config "./tailwind.config.js";
 
/* Now prose and form-* classes are available */

Combining with CSS nesting:

@utility card {
  border-radius: 0.75rem;
  border: 1px solid var(--color-border);
  padding: 1.5rem;
  background: var(--color-surface);
 
  & > h2 {
    font-size: 1.25rem;
    font-weight: 600;
    margin-bottom: 0.5rem;
  }
 
  & > p {
    color: var(--color-muted-foreground);
  }
}

Complex variant selectors:

/* Open state (for details/dialog) */
@variant open (&[open], &[data-state="open"]);
 
/* Parent state variant */
@variant sidebar-open (:merge(.sidebar-open) &);
 
/* Print variant is built-in, but custom media: */
@variant portrait (@media (orientation: portrait));
@variant touch (@media (pointer: coarse));

TypeScript Notes

// Custom utilities are CSS — no TS impact
// But you can create a utility map for documentation:
const customUtilities = {
  "container-prose": "max-width: 65ch centered with inline padding",
  "glass": "frosted glass background with backdrop blur",
  "gradient-text": "gradient fill on text",
  "scrollbar-hidden": "hides scrollbar across all browsers",
} as const;
 
type CustomUtility = keyof typeof customUtilities;

Gotchas

  • @utility names must be single identifiers — No dots, colons, or spaces. @utility my-card works; @utility my.card does not.

  • Custom utilities cannot take arguments@utility spacing($value) does not exist. Fix: Define theme values and use built-in utilities (p-page, gap-section).

  • Plugin compatibility — Not all v3 plugins work with v4 yet. Fix: Check plugin documentation for v4 support, or replicate the plugin's CSS with @utility.

  • @config for plugins only — Using @config with a full v3 config may conflict with @theme. Fix: Only put plugin registration in the JS config; move all theme values to @theme.

  • Variant specificity@variant uses the selector you provide. If your selector has high specificity, it may override other styles unexpectedly. Fix: Use :where() to zero out specificity where needed.

Alternatives

AlternativeUse WhenDon't Use When
@applyYou want to compose existing Tailwind utilities into a classYou can use @utility directly (cleaner)
Component abstractionThe pattern includes HTML structure, not just stylesYou only need a CSS class
Tailwind plugins (JS)You need dynamic utility generation with valuesA static @utility covers your need
PostCSS pluginsYou need transformations beyond Tailwind's scopeTailwind's built-in features suffice

FAQs

What does @utility do and how is it different from @apply?
  • @utility name { ... } creates a new utility class in the utilities layer
  • @apply composes existing Tailwind utilities into a class
  • @utility is cleaner and purpose-built for custom utilities in v4
Do custom utilities work with responsive and state modifiers automatically?

Yes. A utility defined with @utility automatically works with md:, hover:, dark:, and all other modifiers.

How do you create a custom variant in v4?
@variant hocus (&:hover, &:focus-visible);

Usage: <a className="hocus:text-blue-600">Link</a>

Can custom utilities accept arguments like spacing(2rem)?

No. @utility cannot take arguments. Instead, define theme values and use built-in utilities:

@theme {
  --spacing-page: 2rem;
}
/* Now use p-page, gap-page, etc. */
How do you use v3 plugins (like @tailwindcss/typography) in v4?
@import "tailwindcss";
@config "./tailwind.config.js";

Only put plugin registration in the JS config. Keep all theme values in @theme.

Can you use CSS nesting inside an @utility block?
@utility card {
  border-radius: 0.75rem;
  padding: 1.5rem;
 
  & > h2 {
    font-size: 1.25rem;
    font-weight: 600;
  }
}

Yes, native CSS nesting is supported inside @utility.

What naming rules apply to @utility names?

Names must be single identifiers. Hyphens are allowed (my-card), but dots, colons, and spaces are not (my.card will fail).

How do you create a data-attribute variant?
@variant loading (&[data-loading]);

Usage: <button data-loading className="loading:opacity-50">

Gotcha: Your custom variant overrides other styles unexpectedly. What happened?

The variant selector has high specificity. Fix by using :where() to zero out specificity: @variant loading (&:where([data-loading])).

Gotcha: You used @config with a full v3 config alongside @theme. What can go wrong?

Theme values from the JS config can conflict with @theme values in CSS. Only use @config for plugin registration; move all theme values to @theme.

How would you document custom utilities with TypeScript for team awareness?
const customUtilities = {
  "container-prose": "max-width: 65ch centered with inline padding",
  "glass": "frosted glass background with backdrop blur",
} as const;
 
type CustomUtility = keyof typeof customUtilities;
  • Setup — @theme and @import configuration
  • Utilities — built-in utility reference
  • Animations — custom animation utilities
  • Dark Mode — theme-aware custom utilities