From 08cad84bbe2002398655d7f981eae47379e07fc3 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 4 May 2026 17:03:00 +0200 Subject: [PATCH] =?UTF-8?q?Support=20`--default(=E2=80=A6)`=20in=20`--valu?= =?UTF-8?q?e(=E2=80=A6)`=20and=20`--modifier(=E2=80=A6)`=20to=20support=20?= =?UTF-8?q?fallback=20values=20(#19989)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + packages/tailwindcss/src/utilities.test.ts | 128 +++++++++++++++++++++ packages/tailwindcss/src/utilities.ts | 42 +++---- 3 files changed, 151 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 971ad9629..08a0a8282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 37c5945f6..7046663b9 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -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 { diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 2aa25fc25..d5389631a 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -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['value'] - | Extract['modifier'] - >, + | Extract['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 (