Migrate aria theme keys (#18815)

This PR migrates `aria` theme keys when migrating from Tailwind CSS v3
to v4.

While working on improving some of the error messages to get more
insights into why migrating the JS file changed
(https://github.com/tailwindlabs/tailwindcss/pull/18808), I ran into an
issue where I couldn't think of a good comment to why `aria` theme keys
were not being migrated. (Internally we have `aria` "blocked").

So instead of figuring out a good error message..., I just went ahead
and added the migration for `aria` theme keys.


Let's imagine you have the following Tailwind CSS v3 configuration:
```ts
export default {
  content: ['./src/**/*.html'],
  theme: {
    extend: {
      aria: {
        // Built-in (not really, but visible because of intellisense)
        busy: 'busy="true"',

        // Automatically handled by bare values
        foo: 'foo="true"',
    //  ^^^   ^^^            ← same names

        // Not automatically handled by bare values because names differ
        bar: 'baz="true"',
    //  ^^^   ^^^            ← different names

        // Completely custom
        asc: 'sort="ascending"',
        desc: 'sort="descending"',
      },
    },
  },
}
```

Then we would generate the following Tailwind CSS v4 CSS:

```css
@custom-variant aria-bar (&[aria-baz="true"]);
@custom-variant aria-asc (&[aria-sort="ascending"]);
@custom-variant aria-desc (&[aria-sort="descending"]);
```

Notice how we didn't generate a custom variant for `aria-busy` or
`aria-foo` because those are automatically handled by bare values.

We could also emit a comment near the CSS to warn about the fact that
`@custom-variant` will always be sorted _after_ any other built-in
variants.

This could result in slightly different behavior, or different order of
classes when using `prettier-plugin-tailwindcss`.

I don't know how important this is, because before this PR we would just
use `@config './tailwind.config.js';`.
Edit: when using the `@config` we override `aria` and extend it, which
means that it would be in the expected order 🤔

---------

Co-authored-by: Jordan Pittman <thecrypticace@gmail.com>
This commit is contained in:
Robin Malfait 2025-08-28 16:40:38 +02:00 • committed by GitHub
parent 8165e04564
commit 9e498a3e78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 143 additions and 26 deletions

View file

@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Discard matched variants with unknown named values ([#18799](https://github.com/tailwindlabs/tailwindcss/pull/18799))
- Discard matched variants with non-string values ([#18799](https://github.com/tailwindlabs/tailwindcss/pull/18799))
- Show suggestions for known `matchVariant` values ([#18798](https://github.com/tailwindlabs/tailwindcss/pull/18798))
- Migrate `aria` theme keys to `@custom-variant` ([#18815](https://github.com/tailwindlabs/tailwindcss/pull/18815))
## [4.1.12] - 2025-08-13

View file

@ -965,6 +965,89 @@ test(
},
)
test(
'migrate aria theme keys to custom variants',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.ts': ts`
export default {
content: {
relative: true,
files: ['./src/**/*.html'],
},
theme: {
extend: {
aria: {
// Built-in (not really, but visible because of intellisense)
busy: 'busy="true"',
// Automatically handled by bare values
foo: 'foo="true"',
// Quotes are optional in CSS for these kinds of attribute
// selectors
bar: 'bar=true',
// Not automatically handled by bare values because names differ
baz: 'qux="true"',
// Completely custom
asc: 'sort="ascending"',
desc: 'sort="descending"',
},
},
},
}
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(`
"
--- src/input.css ---
@import 'tailwindcss';
@custom-variant aria-baz (&[aria-qux="true"]);
@custom-variant aria-asc (&[aria-sort="ascending"]);
@custom-variant aria-desc (&[aria-sort="descending"]);
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
"
`)
},
)
describe('border compatibility', () => {
test(
'migrate border compatibility',

View file

@ -97,13 +97,14 @@ async function migrateTheme(
designSystem: DesignSystem,
unresolvedConfig: Config,
base: string,
): Promise<string | null> {
): Promise<string> {
// Resolve the config file without applying plugins and presets, as these are
// migrated to CSS separately.
let configToResolve: ConfigFile = {
base,
config: { ...unresolvedConfig, plugins: [], presets: undefined },
reference: false,
src: undefined,
}
let { resolvedConfig, replacedThemeKeys } = resolveConfig(designSystem, [configToResolve])
@ -113,10 +114,47 @@ async function migrateTheme(
removeUnnecessarySpacingKeys(designSystem, resolvedConfig, replacedThemeKeys)
let css = ''
let prevSectionKey = ''
let css = '\n@tw-bucket theme {\n'
css += `\n@theme {\n`
let containsThemeKeys = false
let themeSection: string[] = []
let keyframesCss = ''
let variants = new Map<string, string>()
// Special handling of specific theme keys:
{
if ('keyframes' in resolvedConfig.theme) {
keyframesCss += keyframesToCss(resolvedConfig.theme.keyframes)
delete resolvedConfig.theme.keyframes
}
if ('container' in resolvedConfig.theme) {
let rules = buildCustomContainerUtilityRules(resolvedConfig.theme.container, designSystem)
if (rules.length > 0) {
// Using `theme` instead of `utility` so it sits before the `@layer
// base` with compatibility CSS. While this is technically a utility, it
// makes a bit more sense to emit this closer to the `@theme` values
// since it is needed for backwards compatibility.
css += `\n@tw-bucket theme {\n`
css += toCss([atRule('@utility', 'container', rules)])
css += '}\n' // @tw-bucket
}
delete resolvedConfig.theme.container
}
if ('aria' in resolvedConfig.theme) {
for (let [key, value] of Object.entries(resolvedConfig.theme.aria ?? {})) {
// Will be handled by bare values if the names match.
// E.g.: `aria-foo:flex` should produce `[aria-foo="true"]`
if (new RegExp(`^${key}=(['"]?)true\\1$`).test(`${value}`)) continue
// Create custom variant
variants.set(`aria-${key}`, `&[aria-${value}]`)
}
delete resolvedConfig.theme.aria
}
}
// Convert theme values to CSS custom properties
for (let [key, value] of themeableValues(resolvedConfig.theme)) {
if (typeof value !== 'string' && typeof value !== 'number') {
continue
@ -151,14 +189,9 @@ async function migrateTheme(
}
}
if (key[0] === 'keyframes') {
continue
}
containsThemeKeys = true
let sectionKey = createSectionKey(key)
if (sectionKey !== prevSectionKey) {
css += `\n`
themeSection.push('')
prevSectionKey = sectionKey
}
@ -166,36 +199,36 @@ async function migrateTheme(
resetNamespaces.set(key[0], true)
let property = keyPathToCssProperty([key[0]])
if (property !== null) {
css += ` ${escape(`--${property}`)}-*: initial;\n`
themeSection.push(` ${escape(`--${property}`)}-*: initial;`)
}
}
let property = keyPathToCssProperty(key)
if (property !== null) {
css += ` ${escape(`--${property}`)}: ${value};\n`
themeSection.push(` ${escape(`--${property}`)}: ${value};`)
}
}
if ('keyframes' in resolvedConfig.theme) {
containsThemeKeys = true
css += '\n' + keyframesToCss(resolvedConfig.theme.keyframes)
if (keyframesCss) {
themeSection.push('', keyframesCss)
}
if (!containsThemeKeys) {
return null
if (themeSection.length > 0) {
css += `\n@tw-bucket theme {\n`
css += `\n@theme {\n`
css += themeSection.join('\n') + '\n'
css += '}\n' // @theme
css += '}\n' // @tw-bucket
}
css += '}\n' // @theme
if ('container' in resolvedConfig.theme) {
let rules = buildCustomContainerUtilityRules(resolvedConfig.theme.container, designSystem)
if (rules.length > 0) {
css += '\n' + toCss([atRule('@utility', 'container', rules)])
if (variants.size > 0) {
css += '\n@tw-bucket custom-variant {\n'
for (let [name, selector] of variants) {
css += `@custom-variant ${name} (${selector});\n`
}
css += '}\n'
}
css += '}\n' // @tw-bucket
return css
}
@ -356,7 +389,7 @@ const ALLOWED_THEME_KEYS = [
// Used by @tailwindcss/container-queries
'containers',
]
const BLOCKED_THEME_KEYS = ['supports', 'data', 'aria']
const BLOCKED_THEME_KEYS = ['supports', 'data']
function onlyAllowedThemeValues(theme: ThemeConfig): boolean {
for (let key of Object.keys(theme)) {
if (!ALLOWED_THEME_KEYS.includes(key)) {