static @utility → functional @utility: the upgrade
A static @utility is one CSS rule
for one fixed class. A functional
@utility ends in -* and is a template: the
* is a value slot, and the special --value()
function resolves whatever you put there. This is exactly how every
built-in utility (w-4, p-8, text-cyan-500)
is authored internally — so your custom utilities inherit the full variant
system (hover:, lg:, dark:) for free.
| concept | static @utility | functional @utility |
|---|---|---|
| name shape | @utility tab-4 (literal) |
@utility tab-* (ends in -*) |
| value source | hard-coded in the block | resolved by --value() from the class |
| how many classes | exactly one (.tab-4) |
infinitely many — one rule per used value, JIT-generated |
| variant support | yes — hover:tab-4 |
yes — hover:tab-8, lg:tab-4 (identical) |
| when to use | a single one-off property (content-auto) |
a family of related values (sizes, steps, scales) |
1 · tab-* — the canonical example (gold-check target)
This is the example from the official Tailwind docs. --value(integer)
accepts any whole number; --default(4) makes the bare class
tab resolve to tab-size: 4. The three
<pre> blocks below have real tab characters — notice how the
same source indent renders at different widths because the integer flows
through --value().
def greet(name):
return f"hi {name}"
if cond:
nested()
def greet(name):
return f"hi {name}"
if cond:
nested()
def greet(name):
return f"hi {name}"
if cond:
nested()
def greet(name):
return f"hi {name}"
if cond:
nested()
tab-2: — · tab-4: — · tab-8: — · tab(bare): —
The gold-check asserts tab-4 → tab-size: 4 and
tab-8 → tab-size: 8. The second assertion is the
important one: it proves the integer is actually read from the class and
plumbed through --value(integer) — not a hardcoded fallback.
2 · text-shadow-* — integer plumbed into calc()
Functional utilities aren't limited to pass-through. Here the integer drives
a calc() expression, building a glow scale from a single number.
Gotcha: Tailwind v4.3 does ship a built-in
text-shadow-* (preset scale: text-shadow-sm,
text-shadow-lg…). Your custom @utility text-shadow-*
adds the numeric branch (text-shadow-6) — the resolver picks
the right branch per value. If you only need the presets, don't reinvent.
text-shadow-3: — · text-shadow-6: — · text-shadow-10: —
3 · grid-auto-fill-* — one utility, many declarations
A functional utility body can set multiple properties (just like the
built-in line-clamp-*, which sets four). Here the integer becomes
a minimum card width via minmax(), and the grid auto-fills as many
cards as fit. Click a preset — the CDN's MutationObserver sees the class change
and compiles the new rule on the fly.
class: — · grid-template-columns: —
the --value() resolver — every form
--value() is the bridge between the class suffix and your CSS.
Stacking multiple --value() declarations (or comma-separated
arguments) lets one utility accept theme keys, bare values, and
arbitrary values — each branch that fails to resolve is silently dropped.
| form | syntax | matches | example class |
|---|---|---|---|
| theme key | --value(--tab-size-*) |
keys in @theme |
tab-github ← --tab-size-github: 8 |
| bare value | --value(integer) |
whole numbers (also number, ratio, percentage) |
tab-4 |
| literal | --value("inherit", "unset") |
exact keyword strings (quoted) | tab-inherit |
| arbitrary value | --value([integer]) |
bracketed value of a type (length, color, image, *…) |
tab-[76] |
| default value | --value(integer, --default(4)) |
bare class with no suffix → default | tab → tab-size: 4 |
| all-in-one | --value(--tab-size-*, integer, [integer]) |
theme + bare + arbitrary, resolved left-to-right | tab-github / tab-4 / tab-[76] |
| modifier | --modifier(integer, --default(1)) |
the part after / (e.g. line-height) |
tab-2/3 → modifier 3 |
Heads-up: the older "--value(integer)? with a ?
to make it optional" shorthand you may see in old blog posts does not
exist in v4. The real mechanism is the --default() function.
when: functional @utility vs arbitrary value vs @theme
Three tools, three frequencies. Pick by how often the value recurs. See arbitrary values for the one-off escape hatch and arbitrary properties for one-off CSS properties Tailwind doesn't cover.
| you need… | use | example |
|---|---|---|
| a value once, never again | arbitrary value | top-[117px] |
| a CSS property Tailwind lacks, once | arbitrary property | [mask-type:luminance] |
| one design token reused everywhere | @theme + generated utility |
--color-brand → bg-brand |
| a family of utilities from a formula/scale | functional @utility |
tab-*, grid-auto-fill-*, text-shadow-* |
variants on a one-off value (hover:[mask-type:alpha]) |
arbitrary property (variants work) | — |
| variants on a reusable scale | functional @utility (variants work identically) |
hover:tab-8, lg:grid-auto-fill-6 |
intent → pattern
| intent | pattern | why |
|---|---|---|
| register a parameterized family | @utility tab-* { tab-size: --value(integer); } |
the -* marks a value slot; JIT emits a rule per used value |
| make the bare class work too | --value(integer, --default(4)) |
tab (no number) resolves to the default |
| accept theme keys + numbers + brackets | --value(--tab-size-*, integer, [integer]) |
comma list = left-to-right resolution; misses drop silently |
| set several properties from one value | multiple declarations in the block | mimics built-in line-clamp-* (4 declarations) |
| negative values | separate @utility -inset-* with * -1 |
v4 has no sign flag — register the negative form by hand |
read the /modifier part |
--modifier(integer, --default(1)) |
like built-in text-* reads leading-* after / |
| use it with variants | hover:tab-8, dark:text-shadow-6 |
automatic — functional utilities live in the utilities layer |