Tailwind CSS Handbook v4.3

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#

app.css
@import "tailwindcss";

@utility content-auto {
  content-visibility: auto;
}
HTML
<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 @utility definitions 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 utilities layer, so it beats anything in base or components regardless 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.

CSS
@utility scrollbar-hidden {
  scrollbar-width: none;

  &::-webkit-scrollbar {
    display: none;
  }
}
HTML
<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:

CSS
@utility text-balance-safe {
  text-wrap: pretty;

  @supports (text-wrap: balance) {
    text-wrap: balance;
  }
}
Loading preview…

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:

CSS
@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:

CSS
@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:

CSS
@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:

CSS
@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:

CSS
@theme {
  --tab-size-github: 8;
}

@utility tab-* {
  tab-size: --value(--tab-size-*, integer, [integer]);
}
ClassResolves via
tab-githubthe theme namespace
tab-4a bare integer
tab-[16]an arbitrary integer
tab-foonothing — 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:

CSS
@utility tab-* {
  tab-size: --value(integer, --default(4));
}
Generated
.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():

CSS
@utility text-* {
  font-size: --value(--text-*, [length]);
  line-height: --modifier(--leading-*, [length], [*]);
}

--modifier() accepts the same three forms as --value(), and takes --default(…) too:

CSS
@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:

CSS
@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.

CSS
@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#

app.css
@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)
  );
}
HTML
<!-- 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:

CSS
.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.

  1. 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.
  2. 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.
Case 1 — styling a library's DOM
@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:

Card.vue
<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:

VUE
<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:

CSS
@layer components {
  .card {
    background-color: var(--color-white);
    border-radius: var(--radius-lg);
    padding: --spacing(6);
    box-shadow: var(--shadow-md);
  }
}
HTML
<!-- 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 wantUse
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 + stylesA 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 @utility to accept a selector. The name is a utility name, not a selector — @utility .foo and @utility foo bar are both invalid. Use & inside the block for nesting.
  • Using @apply to build a design system. It reintroduces every problem of hand-written CSS while keeping none of the benefits of utilities.
  • @apply in 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. If tab-github produces no CSS, the theme token or the --value() form is missing — the class name itself is never the error.