From c4fe3620b3a114234afac913da1a2b17f1f6ac23 Mon Sep 17 00:00:00 2001 From: Pouya Saadeghi Date: Wed, 24 Jun 2026 21:49:10 +0300 Subject: [PATCH 1/5] Support prefixes for custom CSS imports Allow `prefix(...)` on imported custom CSS files to scope `@utility` definitions from that import. Custom utilities from prefixed imports now use the public `prefix:utility` candidate format without making that prefix global for core utilities. Imported utility roots are tracked per prefix to avoid exposing unrelated utilities that happen to share the same internal name prefix. Adds coverage for: - custom imports inheriting the Tailwind import prefix - custom imports using their own prefix - prefixed custom imports not prefixing core utilities - avoiding collisions with unrelated `prefix-*` utilities --- packages/tailwindcss/src/index.ts | 61 ++++++++++++ packages/tailwindcss/src/prefix.test.ts | 121 ++++++++++++++++++++++++ 2 files changed, 182 insertions(+) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index dc1742d1ee4e..1df36b660609 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -158,6 +158,7 @@ async function parseCss( let customVariants = new Map void>() let customVariantDependencies = new Map>() let customUtilities: ((designSystem: DesignSystem) => void)[] = [] + let importedUtilityRootsByPrefix = new Map>() let firstThemeRule = null as StyleRule | null let utilitiesNode = null as AtRule | null let variantNodes: AtRule[] = [] @@ -233,6 +234,19 @@ async function parseCss( } let utility = createCssUtility(node) + let importPrefix = ctx.context.importPrefix as string | undefined + if (importPrefix) { + let prefixedParams = `${importPrefix}-${node.params}` + utility = createCssUtility({ ...node, params: prefixedParams }) + + if (utility !== null) { + let roots = importedUtilityRootsByPrefix.get(importPrefix) + if (!roots) importedUtilityRootsByPrefix.set(importPrefix, (roots = new Set())) + + let root = unescape(prefixedParams) + roots.add(root.endsWith('-*') ? root.slice(0, -2) : root) + } + } if (utility === null) { if (!node.params.endsWith('-*')) { if (node.params.endsWith('*')) { @@ -505,13 +519,31 @@ async function parseCss( else if (param.startsWith('prefix(')) { let prefix = param.slice(7, -1) + if (!IS_VALID_PREFIX.test(prefix)) { + throw new Error( + `The prefix "${prefix}" is invalid. Prefixes must be lowercase ASCII letters (a-z) only.`, + ) + } + + let containsTailwindUtilities = false + walk(node.nodes, (child) => { if (child.kind !== 'at-rule') return + if (child.name === '@tailwind' && child.params.startsWith('utilities')) { + containsTailwindUtilities = true + } + if (child.name === '@theme') { child.params += ` prefix(${prefix})` return WalkAction.Skip } }) + + if (containsTailwindUtilities) { + theme.prefix = prefix + } + + node.nodes = [contextNode({ importPrefix: prefix }, node.nodes)] } // Handle important @@ -598,6 +630,35 @@ async function parseCss( let designSystem = buildDesignSystem(theme, utilitiesNode?.src) + if (importedUtilityRootsByPrefix.size > 0) { + let parseCandidate = designSystem.parseCandidate + + designSystem.parseCandidate = (candidate) => { + let [prefix, ...parts] = segment(candidate, ':') + let roots = importedUtilityRootsByPrefix.get(prefix) + if (!roots) return parseCandidate(candidate) + + let base = parts.pop() + if (!base) return [] + + // Imported utilities use `prefix:name` publicly, but are registered as + // `prefix-name` internally because `@utility` names cannot contain `:`. + let internalCandidate = [...parts, `${prefix}-${base}`].join(':') + if (designSystem.theme.prefix) { + internalCandidate = `${designSystem.theme.prefix}:${internalCandidate}` + } + + return parseCandidate(internalCandidate) + .filter((parsedCandidate) => { + return parsedCandidate.kind !== 'arbitrary' && roots.has(parsedCandidate.root) + }) + .map((parsedCandidate) => ({ + ...parsedCandidate, + raw: candidate, + })) + } + } + if (important) { designSystem.important = important } diff --git a/packages/tailwindcss/src/prefix.test.ts b/packages/tailwindcss/src/prefix.test.ts index 799cb22fe27b..01c85f2cc77a 100644 --- a/packages/tailwindcss/src/prefix.test.ts +++ b/packages/tailwindcss/src/prefix.test.ts @@ -311,6 +311,127 @@ test('a prefix can be configured via @import prefix(…)', async () => { expect(await run(['underline', 'hover:line-through', 'custom'], input, options)).toEqual('') }) +test('custom utilities from imports use the Tailwind import prefix', async () => { + let input = css` + @import 'tailwindcss' prefix(tw); + @import './components.css'; + ` + + async function loadStylesheet(id: string, base: string) { + return { + path: '', + base, + content: + id === 'tailwindcss' + ? css` + @tailwind utilities; + ` + : css` + @utility card { + color: red; + } + `, + } + } + + expect(await run(['underline', 'card'], input, { loadStylesheet })).toEqual('') + + expect(await run(['tw:underline', 'tw:card'], input, { loadStylesheet })).toMatchInlineSnapshot(` + " + .tw\\:card { + color: red; + } + + .tw\\:underline { + text-decoration-line: underline; + } + " + `) +}) + +test('custom utilities from prefixed imports use their import prefix', async () => { + let input = css` + @import 'tailwindcss' prefix(tw); + @import './components.css' prefix(ui); + + @utility ui-underline { + color: red; + } + ` + + async function loadStylesheet(id: string, base: string) { + return { + path: '', + base, + content: + id === 'tailwindcss' + ? css` + @tailwind utilities; + ` + : css` + @utility card { + color: red; + } + `, + } + } + + expect( + await run(['underline', 'card', 'tw:card', 'ui:underline'], input, { loadStylesheet }), + ).toEqual('') + + expect(await run(['tw:underline', 'ui:card', 'tw:ui-underline'], input, { loadStylesheet })) + .toMatchInlineSnapshot(` + " + .tw\\:ui-underline, .ui\\:card { + color: red; + } + + .tw\\:underline { + text-decoration-line: underline; + } + " + `) +}) + +test('a prefixed custom import does not prefix Tailwind utilities', async () => { + let input = css` + @import 'tailwindcss'; + @import './components.css' prefix(ui); + ` + + async function loadStylesheet(id: string, base: string) { + return { + path: '', + base, + content: + id === 'tailwindcss' + ? css` + @tailwind utilities; + ` + : css` + @utility card { + color: red; + } + `, + } + } + + expect(await run(['card', 'ui:underline'], input, { loadStylesheet })).toEqual('') + + expect(await run(['underline', 'ui:card'], input, { loadStylesheet })).toMatchInlineSnapshot(` + " + .ui\\:card { + color: red; + } + + .underline { + text-decoration-line: underline; + } + " + `) +}) + test('a prefix must be letters only', async () => { let input = css` @theme reference prefix(__); From ff5857315d1d2e3ed17815ad2320095986d215b0 Mon Sep 17 00:00:00 2001 From: Pouya Saadeghi Date: Thu, 25 Jun 2026 01:39:51 +0300 Subject: [PATCH 2/5] Support prefixes for custom CSS imports --- packages/tailwindcss/src/index.ts | 4 ++- packages/tailwindcss/src/prefix.test.ts | 42 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 1df36b660609..3468e52bd968 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -648,7 +648,7 @@ async function parseCss( internalCandidate = `${designSystem.theme.prefix}:${internalCandidate}` } - return parseCandidate(internalCandidate) + let results = parseCandidate(internalCandidate) .filter((parsedCandidate) => { return parsedCandidate.kind !== 'arbitrary' && roots.has(parsedCandidate.root) }) @@ -656,6 +656,8 @@ async function parseCss( ...parsedCandidate, raw: candidate, })) + + return results.length > 0 ? results : parseCandidate(candidate) } } diff --git a/packages/tailwindcss/src/prefix.test.ts b/packages/tailwindcss/src/prefix.test.ts index 01c85f2cc77a..cb904ce0ef00 100644 --- a/packages/tailwindcss/src/prefix.test.ts +++ b/packages/tailwindcss/src/prefix.test.ts @@ -432,6 +432,48 @@ test('a prefixed custom import does not prefix Tailwind utilities', async () => `) }) +test('custom import prefixes do not shadow variants with the same name', async () => { + let input = css` + @import 'tailwindcss'; + @import './components.css' prefix(sm); + ` + + async function loadStylesheet(id: string, base: string) { + return { + path: '', + base, + content: + id === 'tailwindcss' + ? css` + @theme { + --breakpoint-sm: 40rem; + } + + @tailwind utilities; + ` + : css` + @utility card { + color: red; + } + `, + } + } + + expect(await run(['sm:card', 'sm:flex'], input, { loadStylesheet })).toMatchInlineSnapshot(` + " + .sm\\:card { + color: red; + } + + @media (min-width: 40rem) { + .sm\\:flex { + display: flex; + } + } + " + `) +}) + test('a prefix must be letters only', async () => { let input = css` @theme reference prefix(__); From 8da4109b71f3e82e106265da60ba42e26f3de6ea Mon Sep 17 00:00:00 2001 From: Pouya Saadeghi Date: Thu, 25 Jun 2026 01:44:43 +0300 Subject: [PATCH 3/5] Hide internal utility names for prefixed imports --- packages/tailwindcss/src/index.ts | 16 ++++++++++++++-- packages/tailwindcss/src/prefix.test.ts | 6 ++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 3468e52bd968..6a2c816d8a0f 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -632,11 +632,23 @@ async function parseCss( if (importedUtilityRootsByPrefix.size > 0) { let parseCandidate = designSystem.parseCandidate + let importedUtilityRoots = new Set() + for (let roots of importedUtilityRootsByPrefix.values()) { + for (let root of roots) importedUtilityRoots.add(root) + } + + function parseCandidateWithoutImportedRoots(candidate: string) { + return parseCandidate(candidate).filter((parsedCandidate) => { + return ( + parsedCandidate.kind === 'arbitrary' || !importedUtilityRoots.has(parsedCandidate.root) + ) + }) + } designSystem.parseCandidate = (candidate) => { let [prefix, ...parts] = segment(candidate, ':') let roots = importedUtilityRootsByPrefix.get(prefix) - if (!roots) return parseCandidate(candidate) + if (!roots) return parseCandidateWithoutImportedRoots(candidate) let base = parts.pop() if (!base) return [] @@ -657,7 +669,7 @@ async function parseCss( raw: candidate, })) - return results.length > 0 ? results : parseCandidate(candidate) + return results.length > 0 ? results : parseCandidateWithoutImportedRoots(candidate) } } diff --git a/packages/tailwindcss/src/prefix.test.ts b/packages/tailwindcss/src/prefix.test.ts index cb904ce0ef00..7a45645928f4 100644 --- a/packages/tailwindcss/src/prefix.test.ts +++ b/packages/tailwindcss/src/prefix.test.ts @@ -377,7 +377,9 @@ test('custom utilities from prefixed imports use their import prefix', async () } expect( - await run(['underline', 'card', 'tw:card', 'ui:underline'], input, { loadStylesheet }), + await run(['underline', 'card', 'tw:card', 'ui:underline', 'tw:ui-card'], input, { + loadStylesheet, + }), ).toEqual('') expect(await run(['tw:underline', 'ui:card', 'tw:ui-underline'], input, { loadStylesheet })) @@ -417,7 +419,7 @@ test('a prefixed custom import does not prefix Tailwind utilities', async () => } } - expect(await run(['card', 'ui:underline'], input, { loadStylesheet })).toEqual('') + expect(await run(['card', 'ui-card', 'ui:underline'], input, { loadStylesheet })).toEqual('') expect(await run(['underline', 'ui:card'], input, { loadStylesheet })).toMatchInlineSnapshot(` " From b192f173e3cbca365694ee851783a153b4fa4635 Mon Sep 17 00:00:00 2001 From: Pouya Saadeghi Date: Thu, 25 Jun 2026 01:50:30 +0300 Subject: [PATCH 4/5] Keep custom import theme prefixes scoped --- packages/tailwindcss/src/index.ts | 7 +++- packages/tailwindcss/src/prefix.test.ts | 46 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 6a2c816d8a0f..948132b395df 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -526,6 +526,7 @@ async function parseCss( } let containsTailwindUtilities = false + let themeNodes: AtRule[] = [] walk(node.nodes, (child) => { if (child.kind !== 'at-rule') return @@ -534,13 +535,17 @@ async function parseCss( } if (child.name === '@theme') { - child.params += ` prefix(${prefix})` + themeNodes.push(child) return WalkAction.Skip } }) if (containsTailwindUtilities) { theme.prefix = prefix + + for (let themeNode of themeNodes) { + themeNode.params += ` prefix(${prefix})` + } } node.nodes = [contextNode({ importPrefix: prefix }, node.nodes)] diff --git a/packages/tailwindcss/src/prefix.test.ts b/packages/tailwindcss/src/prefix.test.ts index 7a45645928f4..cf9a2dfda464 100644 --- a/packages/tailwindcss/src/prefix.test.ts +++ b/packages/tailwindcss/src/prefix.test.ts @@ -434,6 +434,52 @@ test('a prefixed custom import does not prefix Tailwind utilities', async () => `) }) +test('a prefixed custom import with a theme does not prefix Tailwind utilities', async () => { + let input = css` + @import 'tailwindcss'; + @import './components.css' prefix(ui); + ` + + async function loadStylesheet(id: string, base: string) { + return { + path: '', + base, + content: + id === 'tailwindcss' + ? css` + @tailwind utilities; + ` + : css` + @theme { + --color-red: red; + } + + @utility card { + color: var(--color-red); + } + `, + } + } + + expect(await run(['ui:underline'], input, { loadStylesheet })).toEqual('') + + expect(await run(['underline', 'ui:card'], input, { loadStylesheet })).toMatchInlineSnapshot(` + " + .ui\\:card { + color: var(--color-red); + } + + .underline { + text-decoration-line: underline; + } + + :root, :host { + --color-red: red; + } + " + `) +}) + test('custom import prefixes do not shadow variants with the same name', async () => { let input = css` @import 'tailwindcss'; From e13d6103ded8e52f12a2ca7fd255826b596f7ae5 Mon Sep 17 00:00:00 2001 From: Pouya Saadeghi Date: Thu, 25 Jun 2026 01:53:36 +0300 Subject: [PATCH 5/5] add test: variant order for prefixed imports --- packages/tailwindcss/src/prefix.test.ts | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/tailwindcss/src/prefix.test.ts b/packages/tailwindcss/src/prefix.test.ts index cf9a2dfda464..edbd6325be04 100644 --- a/packages/tailwindcss/src/prefix.test.ts +++ b/packages/tailwindcss/src/prefix.test.ts @@ -434,6 +434,42 @@ test('a prefixed custom import does not prefix Tailwind utilities', async () => `) }) +test('custom utilities from prefixed imports support variants', async () => { + let input = css` + @import 'tailwindcss'; + @import './components.css' prefix(ui); + ` + + async function loadStylesheet(id: string, base: string) { + return { + path: '', + base, + content: + id === 'tailwindcss' + ? css` + @tailwind utilities; + ` + : css` + @utility card { + color: red; + } + `, + } + } + + expect(await run(['hover:ui:card'], input, { loadStylesheet })).toEqual('') + + expect(await run(['ui:hover:card'], input, { loadStylesheet })).toMatchInlineSnapshot(` + " + @media (hover: hover) { + .ui\\:hover\\:card:hover { + color: red; + } + } + " + `) +}) + test('a prefixed custom import with a theme does not prefix Tailwind utilities', async () => { let input = css` @import 'tailwindcss';