Custom Utilities
Extend Tailwind with @utility — simple, complex and functional utilities using --value(), --modifier() and --default() — plus when @apply is the right tool.
Tailwind ships a lot of utilities, but never all of them. When you need a class the framework does not have, @utility registers it as a first-class citizen: it participates in cascade layers, works with every variant, and sorts correctly against built-in utilities.
Overview#
@import "tailwindcss";
@utility content-auto {
content-visibility: auto;
}<div class="content-auto md:content-auto hover:content-auto">…</div>That is the whole contract. A @utility block is a normal CSS declaration list, and Tailwind takes care of the rest.
Why @utility and not a plain class#
A plain class in your stylesheet gets you a class. @utility gets you a utility, which is a different thing:
- Variants work.
hover:content-auto,lg:content-auto,dark:content-auto,group-hover:content-auto— all generated on demand. - It only ships if you use it. Unused
@utilitydefinitions produce no CSS. - Sorting is automatic. Tailwind orders utilities by how many properties they declare, so a one-property utility can override a many-property one, exactly like the built-ins.
- It lands in the
utilitieslayer, so it beats anything inbaseorcomponentsregardless of specificity.
Complex utilities#
The body of a @utility block is ordinary CSS, so nested selectors, pseudo-elements and vendor-prefixed pseudo-elements all work.
@utility scrollbar-hidden {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}<div class="scrollbar-hidden overflow-x-auto">…</div>The & refers to the generated class. You can nest media queries, @supports blocks and further selectors inside it:
@utility text-balance-safe {
text-wrap: pretty;
@supports (text-wrap: balance) {
text-wrap: balance;
}
}<div class="p-4">
<div class="scrollbar-thin scrollbar-thumb-indigo-500 scrollbar-track-indigo-100 mx-auto max-w-sm overflow-x-auto rounded-lg border border-gray-200 bg-white p-4">
<p class="w-[40rem] text-sm text-gray-700">
A scroll container with a styled, thin scrollbar — no plugin, no vendor pseudo-element and no custom utility required.
</p>
</div>
</div>Functional utilities#
A trailing -* makes a utility functional — it accepts a value. The --value() function decides what values are legal.
Theme values#
Match a namespace from your theme and Tailwind generates one utility per token:
@theme {
--tab-size-2: 2;
--tab-size-4: 4;
--tab-size-github: 8;
}
@utility tab-* {
tab-size: --value(--tab-size-*);
}That produces tab-2, tab-4 and tab-github. Add a token to the theme and the utility appears — no change to the @utility block.
Bare values#
To accept any value of a given CSS type directly in the class name, pass the type instead:
@utility tab-* {
tab-size: --value(integer);
}Now tab-1, tab-76 and tab-1000 all work with no theme entry. The types available to bare values are number, integer, ratio and percentage.
Literal values#
Quote the exact strings you want to allow:
@utility tab-* {
tab-size: --value("inherit", "initial", "unset");
}This generates tab-inherit, tab-initial and tab-unset and nothing else.
Arbitrary values#
Wrap the type in square brackets to accept square-bracket syntax in markup:
@utility tab-* {
tab-size: --value([integer]);
}tab-[16] now resolves. The full list of arbitrary types is absolute-size, angle, bg-size, color, family-name, generic-name, image, integer, length, line-width, number, percentage, position, ratio, relative-size, url, vector, and * for anything.
Combining them#
--value() takes a list, and Tailwind tries each form in order until one matches. This is how the real built-in utilities are defined:
@theme {
--tab-size-github: 8;
}
@utility tab-* {
tab-size: --value(--tab-size-*, integer, [integer]);
}| Class | Resolves via |
|---|---|
tab-github | the theme namespace |
tab-4 | a bare integer |
tab-[16] | an arbitrary integer |
tab-foo | nothing — no CSS is generated |
An unmatched value silently produces no rule. That is deliberate: a typo becomes a missing style, never an invalid one.
Default values v4.3#
--default(…) lets a functional utility work with no value at all, which is how border and rounded behave:
@utility tab-* {
tab-size: --value(integer, --default(4));
}.tab {
tab-size: 4;
}
.tab-2 {
tab-size: 2;
}The bare tab class now exists alongside every tab-<n>. Before v4.3 you had to declare a separate @utility tab { … } block to get this.
Modifiers#
A modifier is the part after a slash — the /50 in bg-black/50 or the /7 in text-sm/7. Read it with --modifier():
@utility text-* {
font-size: --value(--text-*, [length]);
line-height: --modifier(--leading-*, [length], [*]);
}--modifier() accepts the same three forms as --value(), and takes --default(…) too:
@utility tab-* {
tab-size: --value(integer);
line-height: --modifier(integer, --default(1));
}If the class has no modifier, --modifier() produces nothing and the declaration is dropped, so it is safe to write a declaration that only sometimes applies.
Fractions and ratios#
The ratio type handles slash-separated pairs, which is why aspect-3/4 does not collide with the modifier syntax:
@utility aspect-* {
aspect-ratio: --value(--aspect-ratio-*, ratio, [ratio]);
}That single definition covers aspect-square (theme), aspect-3/4 (bare ratio) and aspect-[7/9] (arbitrary ratio).
Negative utilities#
Negative variants are a separate @utility block whose name starts with -. Note the two declarations of the same property: a declaration whose --value() matches nothing is dropped entirely, so exactly one of the pair survives depending on what was written in the class name.
@utility inset-* {
inset: --spacing(--value(integer));
inset: --value([percentage], [length]);
}
@utility -inset-* {
inset: --spacing(--value(integer) * -1);
inset: calc(--value([percentage], [length]) * -1);
}-inset-4 and -inset-[10%] both work, and neither leaks into the positive utility.
A complete functional utility#
@import "tailwindcss";
@theme {
--grid-min-8: 8rem;
--grid-min-12: 12rem;
--grid-min-16: 16rem;
}
/* Auto-fitting grid columns with a configurable minimum track width. */
@utility grid-auto-* {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(min(--value(--grid-min-*, [length], --default(12rem)), 100%), 1fr)
);
}<!-- Falls back to the 12rem default -->
<div class="grid-auto gap-4">…</div>
<!-- A theme token -->
<div class="grid-auto-16 gap-6">…</div>
<!-- An arbitrary length -->
<div class="grid-auto-[22ch] gap-4">…</div>One block, three call shapes, and every variant (md:grid-auto-16, @lg:grid-auto) comes free. The min(…, 100%) wrapper stops the track from overflowing a narrow container — a detail worth encoding once in the utility rather than remembering at every call site.
@apply#
@apply inlines existing utilities into a hand-written rule:
.select2-dropdown {
@apply rounded-b-lg shadow-md;
}It is the most misused feature in Tailwind, so be deliberate about it.
When @apply is the right tool#
There are exactly two situations where @apply is the best answer.
- Markup you do not control. A third-party widget, a rich-text field, a server-rendered form from a CMS, an email template — anywhere you cannot add classes to the elements themselves.
- Scoped component styles. A Vue, Svelte or Astro
<style>block, or a CSS module, where you already have a scoped rule and want it to reuse theme-derived values.
@import "tailwindcss";
/* Flatpickr renders this markup; we cannot add classes to it. */
.flatpickr-calendar {
@apply rounded-xl border border-gray-200 bg-white p-3 shadow-lg;
}
.flatpickr-day.selected {
@apply bg-indigo-600 text-white;
}See Utility-First CSS for the longer argument, and Buttons for what extracting a real component looks like.
@reference for scoped styles#
v4 Stylesheets bundled separately from your main CSS — Vue and Svelte <style> blocks, Astro components, CSS modules — do not see your theme variables, custom utilities or custom variants. @apply in those files fails with an unknown-utility error.
@reference imports those definitions without emitting any CSS:
<template>
<h1 class="title">Hello world</h1>
</template>
<style>
@reference "../../app.css";
.title {
@apply text-2xl font-bold text-red-500;
}
</style>If you have not customised your theme at all, you can reference the framework directly and skip the path:
<style>
@reference "tailwindcss";
h1 {
@apply text-2xl font-bold;
}
</style>@layer components as the alternative#
When you want a default that utilities can override — a base style rather than a utility — put it in the components layer:
@layer components {
.card {
background-color: var(--color-white);
border-radius: var(--radius-lg);
padding: --spacing(6);
box-shadow: var(--shadow-md);
}
}<!-- p-10 wins: the utilities layer comes after components -->
<div class="card p-10">…</div>Because components is declared before utilities, any utility beats any component rule regardless of specificity. That is the behaviour you want for opinionated defaults, and it is the one thing @apply inside an un-layered rule cannot give you.
| You want | Use |
|---|---|
| A new class that behaves like a built-in utility | @utility |
| A class that accepts values | @utility name-* with --value() |
| A default that utilities can override | @layer components |
| To style markup you cannot edit | @apply |
Utilities inside a Vue/Svelte <style> block | @reference + @apply |
| A reusable chunk of markup + styles | A component in your template language |
Common mistakes#
- Defining utilities in
@layer utilities. In v4 that produces an ordinary class with no variant support. Use@utility. - Expecting
@utilityto accept a selector. The name is a utility name, not a selector —@utility .fooand@utility foo barare both invalid. Use&inside the block for nesting. - Using
@applyto build a design system. It reintroduces every problem of hand-written CSS while keeping none of the benefits of utilities. @applyin a Vue/Svelte<style>block without@reference. The build fails, or worse, silently drops the rule in some setups.@import "tailwindcss"inside a component style block. Ships the whole framework once per component.- Forgetting that unmatched
--value()forms generate nothing. Iftab-githubproduces no CSS, the theme token or the--value()form is missing — the class name itself is never the error.
Related topics#
- Theme & Design Tokens — the namespaces
--value()reads from - Custom Variants — the
@custom-varianthalf of the story - Arbitrary Values — the one-off alternative to a custom utility
- Functions & Directives — every directive in one reference
- Utility-First CSS — why
@applyis not the answer