Support --default(…) in --value(…) and --modifier(…) to support fallback values (#19989)

This PR adds a new `--default(…)` option that can be used inside
`--value(…)` or `--modifier(…)` such that functional utilities without
an explicit value/modifier can still be defined as a functional utility.
It would also allow you to use a functional utility without a value and
_with_ a modifier, e.g.: `shadow/50`.

---

This allows us to re-implement functional utilities with a default value
in CSS using `@utility`.

Used the explicit `--default(…)` argument of `--value(…)` for a few
reasons.

1. It's explicit about being a falllback value. If you have `@utility
foo-*`, then you want to be able to use `foo`, but `foo-bad` should not
compile.
2. When `--value(…)` is used in (complex) property values (think a bunch
of `calc(…)` expressions), then we don't need a separate property for
this.

One of the ideas was to have a literal fallback:
```css
@utility tab-* {
  tab-size: 4;
  tab-size: --value(number);
}
```

For `tab`, this would compile to:
```css
.tab {
  tab-size: 4;
}
```

For `tab-123`, this would compile to:
```css
.tab {
  tab-size: 4;
  tab-size: 123;
}
```
Getting rid of the `tab-size: 4` would be an option, but it's a common
pattern in real CSS for fallback values (think hex background color,
over a more modern `oklch` color).

For `tab-foo`, this would compile to:
```css
.tab {
  tab-size: 4;
}
```

Which means that we have an infinite amount classes that would result in
the same class, which is bad. We could special case this one because the
internal `value` would still be `null`, but it might be too confusing.

This syntax without the `--default(…)` also means repetition of certain
properties. Add `--modifier(…)` to the mix, and there is even more
repetition going on.

Another option to consider is that the default fallback is just another
option in the `--value(…, 4)`, but if a default fallback is a keyword,
then there is a chance that this might conflict with actual keywords we
interpret.

Main motivation is to be able to re-implement utilities such as
`shadow/50` purely in CSS. It's also something we support in the JS
based APIs, but not in the CSS based one, so while it's a "new" feature,
it's more like a missing feature right now, and often a reason for
people to use the JS based APIs instead.

For consistency reasons, this is also implemented for `--modifier(…)`
such that you can use a default value there. E.g. when re-implementing
`text-sm` where a default `line-height` is set without the explicit use
of a modifier.

Fixes: https://github.com/tailwindlabs/tailwindcss/issues/16824

## Test plan

1. Added a handful of new tests to make sure this functionality works
2. Existing tests still pass
This commit is contained in:
Robin Malfait 2026-05-04 17:03:00 +02:00 • committed by GitHub
parent 0f6f7d480f
commit 08cad84bbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 151 additions and 20 deletions

View file

@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- _Experimental_: add `@container-size` utility ([#18901](https://github.com/tailwindlabs/tailwindcss/pull/18901))
- Allow using `@variant` with stacked variants (e.g. `@variant hover:focus { … }`) ([#19996](https://github.com/tailwindlabs/tailwindcss/pull/19996))
- Allow using `@variant` with compound variants (e.g. `@variant hover, focus { … }`) ([#19996](https://github.com/tailwindlabs/tailwindcss/pull/19996))
- Support `--default(…)` in `--value(…)` and `--modifier(…)` for functional `@utility` definitions ([#19989](https://github.com/tailwindlabs/tailwindcss/pull/19989))
### Fixed

View file

@ -29574,6 +29574,134 @@ describe('custom utilities', () => {
`)
})
test('functional utilities can use `--default(…)` in `--value(…)`', async () => {
let input = css`
@utility tab-* {
tab-size: --value(integer, --default(4));
}
@tailwind utilities;
`
expect(await compileCss(input, ['tab', 'tab-123'])).toMatchInlineSnapshot(`
".tab {
tab-size: 4;
}
.tab-123 {
tab-size: 123;
}"
`)
expect(await compileCss(input, ['tab-foo'])).toEqual('')
})
test('functional utilities can use `--default(…)` in complex expressions', async () => {
let input = css`
@utility tab-* {
tab-size: calc(--value(integer, --default(4)) * 2);
}
@tailwind utilities;
`
expect(await compileCss(input, ['tab', 'tab-123'])).toMatchInlineSnapshot(`
".tab {
tab-size: 8;
}
.tab-123 {
tab-size: 246;
}"
`)
expect(await compileCss(input, ['tab-foo'])).toEqual('')
})
test('functional utilities can use `--default(…)` with `--modifier(…)`', async () => {
let input = css`
@utility tab-* {
tab-size: --value(integer, --default(4));
line-height: --modifier(integer);
}
@tailwind utilities;
`
expect(await compileCss(input, ['tab', 'tab/25'])).toMatchInlineSnapshot(`
".tab\\/25 {
tab-size: 4;
line-height: 25;
}
.tab {
tab-size: 4;
}"
`)
expect(await compileCss(input, ['tab/foo'])).toEqual('')
})
test('functional utilities can use `--default(…)` in `--modifier(…)`', async () => {
let input = css`
@utility tab-* {
tab-size: --value(integer);
line-height: --modifier(integer, --default(1));
}
@tailwind utilities;
`
expect(await compileCss(input, ['tab-123', 'tab-123/25'])).toMatchInlineSnapshot(`
".tab-123 {
tab-size: 123;
line-height: 1;
}
.tab-123\\/25 {
tab-size: 123;
line-height: 25;
}"
`)
expect(await compileCss(input, ['tab-123/foo'])).toEqual('')
})
test('functional utilities can use `--default(…)` in `--value(…)` and `--modifier(…)`', async () => {
let input = css`
@utility tab-* {
tab-size: --value(integer, --default(12));
line-height: --modifier(integer, --default(34));
}
@tailwind utilities;
`
expect(await compileCss(input, ['tab', 'tab/1', 'tab-1', 'tab-1/1'])).toMatchInlineSnapshot(`
".tab {
tab-size: 12;
line-height: 34;
}
.tab-1 {
tab-size: 1;
line-height: 34;
}
.tab-1\\/1 {
tab-size: 1;
line-height: 1;
}
.tab\\/1 {
tab-size: 12;
line-height: 1;
}"
`)
expect(await compileCss(input, ['tab-123/foo'])).toEqual('')
})
test('modifiers', async () => {
let input = css`
@theme reference {

View file

@ -5956,6 +5956,8 @@ export function createCssUtility(node: AtRule) {
// - `--value(number)` resolves a bare value of type number
// - `--value([number])` resolves an arbitrary value of type number
// - `--value(--color)` resolves a theme value in the `color` namespace
// - `--value(--default(4))` resolves to a default value when only the
// root of the functional utility was used.
// - `--value(number, [number])` resolves a bare value of type number or an
// arbitrary value of type number in order.
//
@ -6069,7 +6071,7 @@ export function createCssUtility(node: AtRule) {
arg = arg.replace(/(-\*){2,}/g, '-*')
// Ensure trailing `-*` exists if `-*` isn't present yet
if (arg[0] === '-' && arg[1] === '-' && !arg.includes('-*')) {
if (arg[0] === '-' && arg[1] === '-' && !arg.includes('(') && !arg.includes('-*')) {
arg += '-*'
}
@ -6135,9 +6137,8 @@ export function createCssUtility(node: AtRule) {
let value = candidate.value
let modifier = candidate.modifier
// A value is required for functional utilities, if you want to accept
// just `tab-size`, you'd have to use a static utility.
if (value === null) return
// Functional CSS utilities must resolve at least one `--value(…)`.
// Use `--default(…)` inside `--value(…)` for the omitted-value case.
// Whether `--value(…)` was used
let usedValueFn = false
@ -6197,30 +6198,21 @@ export function createCssUtility(node: AtRule) {
}
// Drop the declaration in case we couldn't resolve the value
usedValueFn ||= false
shouldRemoveDeclaration = true
return WalkAction.Stop
}
// Modifier function, e.g.: `--modifier(integer)`
else if (fnNode.value === '--modifier') {
// If there is no modifier present in the candidate, then the
// declaration can be removed.
if (modifier === null) {
shouldRemoveDeclaration = true
return WalkAction.Stop
}
usedModifierFn = true
let replacement = resolveValueFunction(modifier, fnNode, designSystem)
if (replacement) {
let resolved = resolveValueFunction(modifier, fnNode, designSystem)
if (resolved) {
resolvedModifierFn = true
return WalkAction.ReplaceSkip(replacement.nodes)
return WalkAction.ReplaceSkip(resolved.nodes)
}
// Drop the declaration in case we couldn't resolve the value
usedModifierFn ||= false
shouldRemoveDeclaration = true
return WalkAction.Stop
}
@ -6238,7 +6230,7 @@ export function createCssUtility(node: AtRule) {
if (!usedValueFn || !resolvedValueFn) return null
// Used `--modifier(…)` but nothing resolved
if (usedModifierFn && !resolvedModifierFn) return null
if (usedModifierFn && !resolvedModifierFn && modifier !== null) return null
// Resolved `--value(ratio)` and `--modifier(…)`, which is invalid
if (resolvedRatioValue && resolvedModifierFn) return null
@ -6309,13 +6301,23 @@ export function createCssUtility(node: AtRule) {
}
function resolveValueFunction(
value: NonNullable<
value:
| Extract<Candidate, { kind: 'functional' }>['value']
| Extract<Candidate, { kind: 'functional' }>['modifier']
>,
| Extract<Candidate, { kind: 'functional' }>['modifier'],
fn: ValueParser.ValueFunctionNode,
designSystem: DesignSystem,
): { nodes: ValueParser.ValueAstNode[]; ratio?: boolean } | undefined {
// No value provided, we can try `--default(…)`
if (value === null) {
for (let arg of fn.nodes) {
// Resolve default value, e.g.: `--default(…)`
if (arg.kind === 'function' && arg.value === '--default') {
return { nodes: arg.nodes }
}
}
return
}
for (let arg of fn.nodes) {
// Resolve literal value, e.g.: `--modifier('closest-side')`
if (