Allow trailing dash in functional utility names (#19696)

## Problem

Tailwind 4.2.0 introduced stricter `@utility` name validation (#19524)
that rejects functional utility names where the root ends with a dash
after stripping the `-*` suffix. This breaks a valid and useful naming
pattern where a double dash separates the CSS property from a value
scale:

```css
@utility border--* {
  border-color: --value(--color-border-*, [color]);
}
```

This produces: `border--0`, `border--1`, `border--2`, etc.

The error message is:

> `@utility border--*` defines an invalid utility name. Utilities should
be alphanumeric and start with a lowercase letter.

## Why this pattern matters

The double-dash convention creates a clear visual grammar in class
names. The first segment names the CSS property, and the double dash
separates it from the semantic scale value. In a dense className string
like `border border--0 background--0 content--4`, the scale values (0,
0, 4) are immediately scannable, distinct from the single-dash property
names around them.

This pattern is actively used in production design systems for semantic
color scales (background, content, border, shadow) with values from
0-10.

## Why the restriction is unnecessary

The validation comment states the concern is that `border--*` could
match the bare class `border-` when using default values. However, this
edge case is already handled:

1. **`findRoots` in `candidate.ts`** (line 887) already rejects empty
values: `if (root[1] === '') break`
2. **The Oxide scanner** already extracts double-dash candidates
correctly, as confirmed by existing tests: `("items--center",
vec!["items--center"])`

The candidate parser and scanner both handle this case. The validation
was an overcorrection.

## Changes

- Removed the trailing-dash check from `isValidFunctionalUtilityName` in
`utilities.ts`
- Updated the existing unit test from `['foo--*', false]` to `['foo--*',
true]`
- Added an integration test proving `@utility border--*` compiles
correctly with theme values

## Test results

All 4121 tests pass across the tailwindcss package, including the new
integration test.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Kirk Ouimet 2026-02-19 07:40:37 -07:00 • committed by GitHub
parent 7a54d15f72
commit d15d92ca60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 47 additions and 12 deletions

View file

@ -11,6 +11,10 @@ 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))
### Fixed
- Allow trailing dash in functional utility names for backwards compatibility ([#19696](https://github.com/tailwindlabs/tailwindcss/pull/19696))
## [4.2.0] - 2026-02-18
### Added

View file

@ -28428,7 +28428,7 @@ describe('custom utilities', () => {
test.each([
['foo', false], // Simple name, missing '-*' suffix
['foo-*', true], // Simple name
['foo--*', false], // Root should not end in `-`
['foo--*', true], // Root ending in `-` is valid (e.g. `border--*`)
['-foo-*', true], // Simple name (negative)
['foo-bar-*', true], // With dashes
['foo_bar-*', true], // With underscores
@ -28900,6 +28900,38 @@ describe('custom utilities', () => {
expect(await compileCss(input, ['tab-3', 'tab-gitlab'])).toEqual('')
})
test('functional utility with double-dash separator', async () => {
let input = css`
@theme reference {
--color-border-0: #e5e7eb;
--color-border-1: #d1d5db;
--color-border-2: #9ca3af;
}
@utility border--* {
border-color: --value(--color-border-*, [color]);
}
@tailwind utilities;
`
expect(await compileCss(input, ['border--0', 'border--1', 'border--2']))
.toMatchInlineSnapshot(`
".border--0 {
border-color: var(--color-border-0, #e5e7eb);
}
.border--1 {
border-color: var(--color-border-1, #d1d5db);
}
.border--2 {
border-color: var(--color-border-2, #9ca3af);
}"
`)
expect(await compileCss(input, ['border--3'])).toEqual('')
})
test('resolving values from `@theme`, with `--tab-size-*` syntax', async () => {
let input =
// Explicitly not using the css tagged template literal so that

View file

@ -6659,22 +6659,21 @@ export function isValidFunctionalUtilityName(name: string): boolean {
let root = match[0]
let value = name.slice(root.length)
// Root should not end in `-` if there is no value
//
// `tab-size--*`
// --------- Root
// -- Suffix
//
// Because with default values, this could match `tab-size-` which is invalid.
if (value.length === 0 && root.endsWith('-')) {
return false
}
// No remaining value is valid
//
// `tab-size-*`
// -------- Root
// -- Suffix
//
// Backwards compatibility: a root ending in `-` was valid and correctly
// scanned by Oxide. This means that custom utilities can result in candidates
// such as `foo--bar`.
//
// We might want to revisit this for Tailwind CSS v5, but for now we have to
// make it backwards compatible.
//
// PR: https://github.com/tailwindlabs/tailwindcss/pull/19696
//
if (value.length === 0) {
return true
}