diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7f3f1b4c55e..b7a97d63d85e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- _Upgrade (experimental)_: Rename `drop-shadow` to `drop-shadow-sm` and `drop-shadow-sm` to `drop-shadow-xs` ([#14875](https://github.com/tailwindlabs/tailwindcss/pull/14875))
- _Upgrade (experimental)_: Rename `rounded` to `rounded-sm` and `rounded-sm` to `rounded-xs` ([#14875](https://github.com/tailwindlabs/tailwindcss/pull/14875))
- _Upgrade (experimental)_: Rename `blur` to `blur-sm` and `blur-sm` to `blur-xs` ([#14875](https://github.com/tailwindlabs/tailwindcss/pull/14875))
+- _Upgrade (experimental)_: Migrate `theme()` usage and JS config files to use the new `--spacing` multiplier where possible ([#14905](https://github.com/tailwindlabs/tailwindcss/pull/14905))
### Fixed
diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts
index ccc6e22f96cb..5db64a52bb1e 100644
--- a/integrations/upgrade/js-config.test.ts
+++ b/integrations/upgrade/js-config.test.ts
@@ -1415,4 +1415,260 @@ describe('border compatibility', () => {
`)
},
)
+
+ test(
+ 'migrates extended spacing keys',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ content: ['./src/**/*.html'],
+ theme: {
+ extend: {
+ spacing: {
+ 2: '0.5rem',
+ 4.5: '1.125rem',
+ 5.5: '1.375em', // Units are different from --spacing scale
+ 13: '3.25rem',
+ 100: '100px',
+ miami: '1337px',
+ },
+ },
+ },
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+
+ .container {
+ width: theme(spacing.2);
+ width: theme(spacing[4.5]);
+ width: theme(spacing[5.5]);
+ width: theme(spacing[13]);
+ width: theme(spacing[100]);
+ width: theme(spacing.miami);
+ }
+ `,
+ 'src/index.html': html`
+
+ `,
+ },
+ },
+ async ({ exec, fs }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
+ "
+ --- src/index.html ---
+
+
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @theme {
+ --spacing-100: 100px;
+ --spacing-5_5: 1.375em;
+ --spacing-miami: 1337px;
+ }
+
+ /*
+ 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);
+ }
+ }
+
+ /*
+ Form elements have a 1px border by default 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 \`border-0\` to
+ any form elements that shouldn't have a border.
+ */
+ @layer base {
+ input:where(:not([type='button'], [type='reset'], [type='submit'])),
+ select,
+ textarea {
+ border-width: 0;
+ }
+ }
+
+ .container {
+ width: calc(var(--spacing) * 2);
+ width: calc(var(--spacing) * 4.5);
+ width: var(--spacing-5_5);
+ width: calc(var(--spacing) * 13);
+ width: var(--spacing-100);
+ width: var(--spacing-miami);
+ }
+ "
+ `)
+ },
+ )
+
+ test(
+ 'retains overwriting spacing scale',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ content: ['./src/**/*.html'],
+ theme: {
+ spacing: {
+ 2: '0.5rem',
+ 4.5: '1.125rem',
+ 5.5: '1.375em',
+ 13: '3.25rem',
+ 100: '100px',
+ miami: '1337px',
+ },
+ },
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+
+ .container {
+ width: theme(spacing.2);
+ width: theme(spacing[4.5]);
+ width: theme(spacing[5.5]);
+ width: theme(spacing[13]);
+ width: theme(spacing[100]);
+ width: theme(spacing.miami);
+ }
+ `,
+ 'src/index.html': html`
+
+ `,
+ },
+ },
+ async ({ exec, fs }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
+ "
+ --- src/index.html ---
+
+
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @theme {
+ --spacing-*: initial;
+ --spacing-2: 0.5rem;
+ --spacing-13: 3.25rem;
+ --spacing-100: 100px;
+ --spacing-4_5: 1.125rem;
+ --spacing-5_5: 1.375em;
+ --spacing-miami: 1337px;
+ }
+
+ /*
+ 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);
+ }
+ }
+
+ /*
+ Form elements have a 1px border by default 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 \`border-0\` to
+ any form elements that shouldn't have a border.
+ */
+ @layer base {
+ input:where(:not([type='button'], [type='reset'], [type='submit'])),
+ select,
+ textarea {
+ border-width: 0;
+ }
+ }
+
+ .container {
+ width: var(--spacing-2);
+ width: var(--spacing-4_5);
+ width: var(--spacing-5_5);
+ width: var(--spacing-13);
+ width: var(--spacing-100);
+ width: var(--spacing-miami);
+ }
+ "
+ `)
+ },
+ )
})
diff --git a/packages/@tailwindcss-upgrade/src/migrate-js-config.ts b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts
index be33b726bb4e..63fc094db8fb 100644
--- a/packages/@tailwindcss-upgrade/src/migrate-js-config.ts
+++ b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts
@@ -12,10 +12,11 @@ import {
} from '../../tailwindcss/src/compat/apply-config-to-theme'
import { keyframesToRules } from '../../tailwindcss/src/compat/apply-keyframes-to-theme'
import { resolveConfig, type ConfigFile } from '../../tailwindcss/src/compat/config/resolve-config'
-import type { ThemeConfig } from '../../tailwindcss/src/compat/config/types'
+import type { ResolvedConfig, ThemeConfig } from '../../tailwindcss/src/compat/config/types'
import { darkModePlugin } from '../../tailwindcss/src/compat/dark-mode'
import type { DesignSystem } from '../../tailwindcss/src/design-system'
import { escape } from '../../tailwindcss/src/utils/escape'
+import { isValidSpacingMultiplier } from '../../tailwindcss/src/utils/infer-data-type'
import { findStaticPlugins, type StaticPluginOptions } from './utils/extract-static-plugins'
import { info } from './utils/renderer'
@@ -101,6 +102,8 @@ async function migrateTheme(
Array.from(replacedThemeKeys.entries()).map(([key]) => [key, false]),
)
+ removeUnnecessarySpacingKeys(designSystem, resolvedConfig, replacedThemeKeys)
+
let prevSectionKey = ''
let css = '\n@tw-bucket theme {\n'
css += `\n@theme {\n`
@@ -317,3 +320,42 @@ function patternSourceFiles(source: { base: string; pattern: string }): string[]
scanner.scan()
return scanner.files
}
+
+function removeUnnecessarySpacingKeys(
+ designSystem: DesignSystem,
+ resolvedConfig: ResolvedConfig,
+ replacedThemeKeys: Set,
+) {
+ // We want to keep the spacing scale as-is if the user is overwriting
+ if (replacedThemeKeys.has('spacing')) return
+
+ // Ensure we have a spacing multiplier
+ let spacingScale = designSystem.theme.get(['--spacing'])
+ if (!spacingScale) return
+
+ let [spacingMultiplier, spacingUnit] = splitNumberAndUnit(spacingScale)
+ if (!spacingMultiplier || !spacingUnit) return
+
+ if (spacingScale && !replacedThemeKeys.has('spacing')) {
+ for (let [key, value] of Object.entries(resolvedConfig.theme.spacing ?? {})) {
+ let [multiplier, unit] = splitNumberAndUnit(value as string)
+ if (multiplier === null) continue
+
+ if (!isValidSpacingMultiplier(key)) continue
+ if (unit !== spacingUnit) continue
+
+ if (parseFloat(multiplier) === Number(key) * parseFloat(spacingMultiplier)) {
+ delete resolvedConfig.theme.spacing[key]
+ designSystem.theme.clearNamespace(escape(`--spacing-${key.replaceAll('.', '_')}`), 0)
+ }
+ }
+ }
+}
+
+function splitNumberAndUnit(value: string): [string, string] | [null, null] {
+ let match = value.match(/^([0-9.]+)(.*)$/)
+ if (!match) {
+ return [null, null]
+ }
+ return [match[1], match[2]]
+}
diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.test.ts
index 3dcbaeecac87..0e6850672586 100644
--- a/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.test.ts
+++ b/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.test.ts
@@ -9,14 +9,18 @@ test.each([
['[color:red]', '[color:red]'],
// Handle special cases around `.1` in the `theme(…)`
- ['[--value:theme(spacing.1)]', '[--value:var(--spacing-1)]'],
+ ['[--value:theme(spacing.1)]', '[--value:calc(var(--spacing)*1)]'],
['[--value:theme(fontSize.xs.1.lineHeight)]', '[--value:var(--text-xs--line-height)]'],
+ ['[--value:theme(spacing[1.25])]', '[--value:calc(var(--spacing)*1.25)]'],
+
+ // Should not convert invalid spacing values to calc
+ ['[--value:theme(spacing[1.1])]', '[--value:theme(spacing[1.1])]'],
// Convert to `var(…)` if we can resolve the path
['[color:theme(colors.red.500)]', '[color:var(--color-red-500)]'], // Arbitrary property
['[color:theme(colors.red.500)]/50', '[color:var(--color-red-500)]/50'], // Arbitrary property + modifier
['bg-[theme(colors.red.500)]', 'bg-[var(--color-red-500)]'], // Arbitrary value
- ['bg-[size:theme(spacing.4)]', 'bg-[size:var(--spacing-4)]'], // Arbitrary value + data type hint
+ ['bg-[size:theme(spacing.4)]', 'bg-[size:calc(var(--spacing)*4)]'], // Arbitrary value + data type hint
// Convert to `var(…)` if we can resolve the path, but keep fallback values
['bg-[theme(colors.red.500,red)]', 'bg-[var(--color-red-500,red)]'],
@@ -79,15 +83,20 @@ test.each([
// Variants, we can't use `var(…)` especially inside of `@media(…)`. We can
// still upgrade the `theme(…)` to the modern syntax.
- ['max-[theme(spacing.4)]:flex', 'max-[theme(--spacing-4)]:flex'],
+ ['max-[theme(screens.lg)]:flex', 'max-[theme(--breakpoint-lg)]:flex'],
+ // There are no variables for `--spacing` multiples, so we can't convert this
+ ['max-[theme(spacing.4)]:flex', 'max-[theme(spacing.4)]:flex'],
// This test in itself doesn't make much sense. But we need to make sure
// that this doesn't end up as the modifier in the candidate itself.
- ['max-[theme(spacing.4/50)]:flex', 'max-[theme(--spacing-4/50)]:flex'],
+ ['max-[theme(spacing.4/50)]:flex', 'max-[theme(spacing.4/50)]:flex'],
// `theme(…)` calls in another CSS function is replaced correctly.
// Additionally we remove unnecessary whitespace.
- ['grid-cols-[min(50%_,_theme(spacing.80))_auto]', 'grid-cols-[min(50%,var(--spacing-80))_auto]'],
+ [
+ 'grid-cols-[min(50%_,_theme(spacing.80))_auto]',
+ 'grid-cols-[min(50%,calc(var(--spacing)*80))_auto]',
+ ],
// `theme(…)` calls valid in v3, but not in v4 should still be converted.
['[--foo:theme(transitionDuration.500)]', '[--foo:theme(transitionDuration.500)]'],
@@ -110,51 +119,66 @@ test.each([
'[--foo:theme(colors.red.500/50/50)_var(--color-blue-200)]/50',
],
])('%s => %s', async (candidate, result) => {
+ let designSystem = await __unstable__loadDesignSystem(
+ css`
+ @import 'tailwindcss';
+ `,
+ {
+ base: __dirname,
+ },
+ )
+
+ expect(themeToVar(designSystem, {}, candidate)).toEqual(result)
+})
+
+test('extended space scale converts to var or calc', async () => {
+ let designSystem = await __unstable__loadDesignSystem(
+ css`
+ @import 'tailwindcss';
+ @theme {
+ --spacing-2: 2px;
+ --spacing-miami: 0.875rem;
+ }
+ `,
+ {
+ base: __dirname,
+ },
+ )
+ expect(themeToVar(designSystem, {}, '[--value:theme(spacing.1)]')).toEqual(
+ '[--value:calc(var(--spacing)*1)]',
+ )
+ expect(themeToVar(designSystem, {}, '[--value:theme(spacing.2)]')).toEqual(
+ '[--value:var(--spacing-2)]',
+ )
+ expect(themeToVar(designSystem, {}, '[--value:theme(spacing.miami)]')).toEqual(
+ '[--value:var(--spacing-miami)]',
+ )
+ expect(themeToVar(designSystem, {}, '[--value:theme(spacing.nyc)]')).toEqual(
+ '[--value:theme(spacing.nyc)]',
+ )
+})
+
+test('custom space scale converts to var', async () => {
let designSystem = await __unstable__loadDesignSystem(
css`
@import 'tailwindcss';
@theme {
- --spacing-px: 1px;
- --spacing-0: 0px;
- --spacing-0_5: 0.125rem;
+ --spacing-*: initial;
--spacing-1: 0.25rem;
- --spacing-1_5: 0.375rem;
--spacing-2: 0.5rem;
- --spacing-2_5: 0.625rem;
- --spacing-3: 0.75rem;
- --spacing-3_5: 0.875rem;
- --spacing-4: 1rem;
- --spacing-5: 1.25rem;
- --spacing-6: 1.5rem;
- --spacing-7: 1.75rem;
- --spacing-8: 2rem;
- --spacing-9: 2.25rem;
- --spacing-10: 2.5rem;
- --spacing-11: 2.75rem;
- --spacing-12: 3rem;
- --spacing-14: 3.5rem;
- --spacing-16: 4rem;
- --spacing-20: 5rem;
- --spacing-24: 6rem;
- --spacing-28: 7rem;
- --spacing-32: 8rem;
- --spacing-36: 9rem;
- --spacing-40: 10rem;
- --spacing-44: 11rem;
- --spacing-48: 12rem;
- --spacing-52: 13rem;
- --spacing-56: 14rem;
- --spacing-60: 15rem;
- --spacing-64: 16rem;
- --spacing-72: 18rem;
- --spacing-80: 20rem;
- --spacing-96: 24rem;
}
`,
{
base: __dirname,
},
)
-
- expect(themeToVar(designSystem, {}, candidate)).toEqual(result)
+ expect(themeToVar(designSystem, {}, '[--value:theme(spacing.1)]')).toEqual(
+ '[--value:var(--spacing-1)]',
+ )
+ expect(themeToVar(designSystem, {}, '[--value:theme(spacing.2)]')).toEqual(
+ '[--value:var(--spacing-2)]',
+ )
+ expect(themeToVar(designSystem, {}, '[--value:theme(spacing.3)]')).toEqual(
+ '[--value:theme(spacing.3)]',
+ )
})
diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.ts b/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.ts
index 4934108e85d2..21ddf7dcd1c7 100644
--- a/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.ts
+++ b/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.ts
@@ -7,6 +7,7 @@ import {
} from '../../../../tailwindcss/src/candidate'
import { keyPathToCssProperty } from '../../../../tailwindcss/src/compat/apply-config-to-theme'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
+import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type'
import { segment } from '../../../../tailwindcss/src/utils/segment'
import { toKeyPath } from '../../../../tailwindcss/src/utils/to-key-path'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
@@ -199,9 +200,17 @@ export function createConverter(designSystem: DesignSystem, { prettyPrint = fals
function toVar(path: string, fallback?: string) {
let variable = pathToVariableName(path)
- if (!variable) return null
+ if (variable) return fallback ? `var(${variable}, ${fallback})` : `var(${variable})`
+
+ let keyPath = toKeyPath(path)
+ if (keyPath[0] === 'spacing' && designSystem.theme.get(['--spacing'])) {
+ let multiplier = keyPath[1]
+ if (!isValidSpacingMultiplier(multiplier)) return null
+
+ return 'calc(var(--spacing) * ' + multiplier + ')'
+ }
- return fallback ? `var(${variable}, ${fallback})` : `var(${variable})`
+ return null
}
function toTheme(path: string, fallback?: string) {
diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts
index 321c7a6b8113..f57c4cccec9d 100644
--- a/packages/tailwindcss/src/utilities.ts
+++ b/packages/tailwindcss/src/utilities.ts
@@ -2,7 +2,7 @@ import { atRoot, atRule, decl, styleRule, type AstNode } from './ast'
import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate'
import type { Theme, ThemeKey } from './theme'
import { DefaultMap } from './utils/default-map'
-import { inferDataType, isPositiveInteger } from './utils/infer-data-type'
+import { inferDataType, isPositiveInteger, isValidSpacingMultiplier } from './utils/infer-data-type'
import { replaceShadowColors } from './utils/replace-shadow-colors'
import { segment } from './utils/segment'
@@ -397,9 +397,7 @@ export function createUtilities(theme: Theme) {
handleBareValue: ({ value }) => {
let multiplier = theme.resolve(null, ['--spacing'])
if (!multiplier) return null
-
- let num = Number(value)
- if (num < 0 || num % 0.25 !== 0 || String(num) !== value) return null
+ if (!isValidSpacingMultiplier(value)) return null
return `calc(${multiplier} * ${value})`
},
diff --git a/packages/tailwindcss/src/utils/infer-data-type.ts b/packages/tailwindcss/src/utils/infer-data-type.ts
index 53abed449c5d..6aa34fbe1d7e 100644
--- a/packages/tailwindcss/src/utils/infer-data-type.ts
+++ b/packages/tailwindcss/src/utils/infer-data-type.ts
@@ -328,3 +328,11 @@ export function isPositiveInteger(value: any) {
let num = Number(value)
return Number.isInteger(num) && num >= 0 && String(num) === String(value)
}
+
+/**
+ * Returns true if the value is either a positive whole number or a multiple of 0.25.
+ */
+export function isValidSpacingMultiplier(value: any) {
+ let num = Number(value)
+ return num >= 0 && num % 0.25 === 0 && String(num) === String(value)
+}