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:
@utilityfor reusable composite utility classes@variantfor custom state/pseudo selectors- Glass morphism effect as a utility
- Gradient text utility using theme variables
- Custom
aria-current,data-loadingvariants - All custom utilities support responsive/hover modifiers automatically
Deep Dive
How It Works
@utility name { ... }creates a utility class.namein 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 componentsin 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
-
@utilitynames must be single identifiers — No dots, colons, or spaces.@utility my-cardworks;@utility my.carddoes 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. -
@configfor plugins only — Using@configwith 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 —
@variantuses 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
| Alternative | Use When | Don't Use When |
|---|---|---|
@apply | You want to compose existing Tailwind utilities into a class | You can use @utility directly (cleaner) |
| Component abstraction | The pattern includes HTML structure, not just styles | You only need a CSS class |
| Tailwind plugins (JS) | You need dynamic utility generation with values | A static @utility covers your need |
| PostCSS plugins | You need transformations beyond Tailwind's scope | Tailwind'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@applycomposes existing Tailwind utilities into a class@utilityis 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;Related
- Setup — @theme and @import configuration
- Utilities — built-in utility reference
- Animations — custom animation utilities
- Dark Mode — theme-aware custom utilities