Tailwind v4 @utility — Define Custom Utilities in CSS, Not JavaScript

Use the Tailwind v4 @utility directive to build static and functional custom utility classes in CSS with value typing and variants — inside a Laravel app.

Steven Richardson
Steven Richardson
· 8 min read

You upgrade a Laravel app from Tailwind v3 to v4, the build goes green, and then half your custom classes are gone. The plugin({ addUtilities: ... }) block you had in tailwind.config.js does nothing now — because there is no tailwind.config.js anymore. The replacement is the Tailwind v4 @utility directive, and it lives in CSS, not JavaScript. It is simpler than the plugin API it replaces, and the functional form does things the old addUtilities never could.

I migrated a production design system through this. Here's the whole story: static utilities, functional utilities with typed values, how variants compose for free, and the exact v3-to-v4 migration. If you haven't done the framework upgrade itself yet, start with the Laravel Tailwind v3 to v4 upgrade tool walkthrough — this article assumes you're already on v4 with @tailwindcss/vite.

Define a static @utility#

Start with the simplest case: a class that outputs a fixed set of declarations. In v3 this meant writing a JavaScript plugin. In v4 you write the CSS directly. Open resources/css/app.css — the file that holds your @import "tailwindcss" line — and add a @utility block at the top level (not nested inside @layer).

/* resources/css/app.css */
@import "tailwindcss";

@utility content-auto {
  content-visibility: auto;
}

That registers a content-auto class in the utilities layer alongside every built-in Tailwind class. Use it in a Blade view exactly like any other utility:

<div class="content-auto">
  {{-- skipped when off-screen --}}
</div>

If the utility needs more than a flat list of declarations — a pseudo-element, a nested selector — use nesting inside the same block:

@utility scrollbar-hidden {
  &::-webkit-scrollbar {
    display: none;
  }
}

No config file, no JS, no build plugin. The declaration is the utility.

Define a functional @utility with arbitrary values#

Static utilities cover the boring 90%. The interesting part is functional utilities — classes that take a value. The utility name ends in -*, and the special --value() function resolves whatever the user typed after the dash.

Here's a tab-* utility that accepts an arbitrary integer:

@utility tab-* {
  /* --value([integer]) accepts bracketed arbitrary values: tab-[4] */
  tab-size: --value([integer]);
}
<pre class="tab-[4]">...</pre>

The square brackets in --value([integer]) are the signal for arbitrary values — the user writes tab-[4], tab-[8], anything. The available arbitrary data types are a long list: length, color, integer, number, percentage, angle, url, image, ratio, and more. Pick the one that matches the CSS property so Tailwind rejects nonsense at build time instead of emitting broken CSS.

This is the form nobody demonstrates, and it's the one that earns its keep. The brief's real-world example is a mask fade — a functional utility wrapping mask-image:

@utility mask-fade-* {
  mask-image: linear-gradient(black 0, transparent --value([length]));
}
<img class="mask-fade-[8rem]" src="/hero.jpg" alt="">

If you want that effect without rolling your own utility, Tailwind 4.1 ships a whole family for it — see the Tailwind v4 mask-* utilities for fading and revealing images. But the pattern above is how you'd build any property Tailwind doesn't cover.

Typecheck the value against design tokens#

Arbitrary values are an escape hatch. Most of the time you want a functional utility constrained to your design system, so tab-4 resolves to a real token and tab-banana simply doesn't generate. There are three resolution modes, and you can stack them.

Theme tokens use --value(--theme-key-*). Define the tokens in @theme, then point the utility at the namespace:

@theme {
  --tab-size-2: 2;
  --tab-size-4: 4;
  --tab-size-github: 8;
}

@utility tab-* {
  tab-size: --value(--tab-size-*);
}

That matches tab-2, tab-4, and tab-github — and nothing else. Bare values use --value({type}) without brackets, where the type is number, integer, ratio, or percentage. Arbitrary values use the bracketed --value([type]) from the last step. The power move is declaring all three; any declaration that fails to resolve is silently dropped from the output:

@utility tab-* {
  tab-size: --value([integer]);   /* tab-[3]      */
  tab-size: --value(integer);     /* tab-3        */
  tab-size: --value(--tab-size-*); /* tab-github  */
}

Spacing is the most common token namespace. --value(--spacing-*) accepts only the spacing scale, which is exactly what you want for anything measured in your layout rhythm. This is the same token-first thinking behind defining colours once and deriving the rest — the approach I covered in Tailwind v4 OKLCH and color-mix design tokens.

Compose with variants for free#

Here's the part that makes @utility worth the switch over hand-written CSS: anything you register gets the entire variant system automatically. You don't wire anything up. hover:, focus:, responsive prefixes like lg:, and dark: all just work:

<div class="content-auto lg:content-visible">...</div>
<pre class="tab-2 md:tab-4">...</pre>
<img class="mask-fade-[4rem] md:mask-fade-[8rem]" src="/hero.jpg" alt="">

Custom variants compose too. If you've defined your own variant with @custom-variant — the mechanism that also powers class-based dark mode, which I walked through in Tailwind v4 CSS-first dark mode with @custom-variant — your custom utility picks it up with no extra work:

@custom-variant theme-midnight (&:where([data-theme="midnight"] *));
<div class="content-auto theme-midnight:content-visible">...</div>

This is the single biggest reason to prefer @utility over a plain .my-class { ... } rule in @layer components — the component-layer class is invisible to variants, the utility is not.

Migrate a v3 addUtilities plugin to v4 @utility#

If you're carrying a v3 plugin forward, the translation is mechanical. Here's a typical v3 block from a tailwind.config.js:

// Tailwind v3 — tailwind.config.js (gone in v4)
const plugin = require('tailwindcss/plugin')

module.exports = {
  plugins: [
    plugin(function ({ addUtilities }) {
      addUtilities({
        '.content-auto': { 'content-visibility': 'auto' },
        '.scrollbar-hidden': {
          '&::-webkit-scrollbar': { display: 'none' },
        },
      })
    }),
  ],
}

The v4 equivalent drops the JS wrapper entirely and moves the bodies into resources/css/app.css:

@import "tailwindcss";

@utility content-auto {
  content-visibility: auto;
}

@utility scrollbar-hidden {
  &::-webkit-scrollbar {
    display: none;
  }
}

Every static addUtilities entry becomes one @utility block. A matchUtilities entry — the v3 way of accepting a value — becomes a functional @utility name-* with --value(). Delete the plugin, delete the config file if @utility and @theme are all it held, and you're done.

You don't always retire JavaScript, though. @utility handles classes; it can't register variants, base styles, or anything that hooks deeper into the engine. For those, v4 keeps a JS plugin escape hatch via the @plugin directive in CSS. Reach for it only when a CSS-first @utility genuinely can't express what you need — which, for adding classes, is almost never.

Gotchas and Edge Cases#

A few things will bite you on the first pass.

@utility must be top level. Nesting it inside @layer or another rule silently fails — the utility never registers and you'll stare at a class that produces no CSS.

Functional utility names must end in -*. Writing @utility tab and expecting it to accept a value won't work; it has to be @utility tab-*. Conversely, a static utility must not have the -* suffix.

A bare --value(integer) and an arbitrary --value([integer]) are different matchers. The first matches tab-3, the second matches tab-[3]. If you only declare one, the other syntax won't generate — declare both when you want users to have the choice.

Editor autocomplete needs the Tailwind CSS IntelliSense extension to suggest --value() and --modifier() parameters. Without it you're flying blind on token names, so install it before you start defining functional utilities.

Vite has to recompile for new utilities to appear. If a freshly added class isn't showing up, the dev server hasn't picked up the CSS change — restart npm run dev or run a fresh npm run build.

Wrapping Up#

The mental model is short: static @utility name { ... } for fixed classes, functional @utility name-* { ... --value(...) } for classes that take a value, and --value() typing to keep those values inside your design system. It all lives in resources/css/app.css, it all gets variants for free, and the JavaScript plugin is gone for everything except deep engine hooks.

Next, wire your new utilities into responsive components with Tailwind v4 container queries for component-first responsive design, and if you haven't migrated the framework yet, run the official Tailwind v3 to v4 upgrade tool first so the config-to-CSS move is done before you start adding utilities.

FAQ#

What is the @utility directive in Tailwind v4?

@utility is a CSS directive that registers a custom utility class. You write it directly in your stylesheet — @utility content-auto { content-visibility: auto; } — and Tailwind inserts it into the utilities layer next to its built-in classes. It replaces the JavaScript plugin API that v3 used for adding utilities, so your custom classes now live alongside the rest of your design system in CSS.

How is @utility different from addUtilities in Tailwind v3?

In v3 you registered utilities through a JavaScript plugin, calling addUtilities() (or matchUtilities() for value-aware ones) inside tailwind.config.js. In v4 that config file is gone and @utility does the same job in CSS. A static addUtilities entry becomes a @utility name { ... } block, and a matchUtilities entry becomes a functional @utility name-* using --value(). The result is less indirection and no build-time JavaScript for the common case.

Can a Tailwind v4 @utility accept arbitrary values?

Yes. Name the utility with a -* suffix and resolve the input with --value([type]), using square brackets to signal an arbitrary value — for example @utility tab-* { tab-size: --value([integer]); } matches tab-[4]. Supported arbitrary types include length, color, integer, number, percentage, angle, image, and url, so Tailwind can validate the value against the property and skip anything that doesn't fit.

Do custom @utility classes work with variants like hover and dark?

They do, automatically. Any utility registered with @utility is inserted into the utilities layer and gets the full variant system for free — hover:, focus:, responsive prefixes like lg:, dark:, and even your own @custom-variant definitions all compose with no extra wiring. This is the main advantage of @utility over writing a plain class in the components layer, which variants can't reach.

Where do I put @utility in a Laravel project?

Put @utility blocks in resources/css/app.css, the same file that holds your @import "tailwindcss" line, at the top level of the file. Vite — via the @tailwindcss/vite plugin — compiles them on the next npm run dev or npm run build. There is no tailwind.config.js to touch in v4; your utilities and your @theme tokens both live in CSS.

Can I typecheck the value of a functional @utility?

Yes, and you should. --value(--spacing-*) accepts only tokens from your spacing scale, --value(integer) accepts only bare integers, and --value([length]) accepts only arbitrary CSS lengths. You can declare all three forms in one utility; Tailwind resolves each independently and drops any declaration whose value doesn't match, so invalid classes simply never generate.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.