Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
83 changes: 83 additions & 0 deletions integrations/upgrade/js-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand All @@ -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
Expand Down Expand Up @@ -151,51 +189,46 @@ async function migrateTheme(
}
}

if (key[0] === 'keyframes') {
continue
}
containsThemeKeys = true

let sectionKey = createSectionKey(key)
if (sectionKey !== prevSectionKey) {
css += `\n`
themeSection.push('')
prevSectionKey = sectionKey
}

if (resetNamespaces.has(key[0]) && resetNamespaces.get(key[0]) === false) {
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
}

Expand Down Expand Up @@ -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)) {
Expand Down