Skip to content

Commit a144360

Browse files
Add support for prefixes (#14501)
This PR adds support for requiring a custom prefix on utility classes. Prefixes work a bit differently in v4 than they did in v3: - They look like a custom variant: `tw:bg-white` - It is always first in a utility — even before other variants: `tw:hover:bg-white` - It is required on **all** utility classes — even arbitrary properties: `tw:[color:red]` - Prefixes also apply to generated CSS variables which will be separated by a dash: `--tw-color-white: #fff;` - Only alpha (a-z) characters are allowed in a prefix — so no `#tw#` or `__` or similar prefixes are allowed To configure a prefix you can use add `prefix(tw)` to your theme or when importing Tailwind CSS like so: ```css /* when importing `tailwindcss` */ @import 'tailwindcss' prefix(tw); /* when importing the theme separately */ @import 'tailwindcss/theme' prefix(tw); /* or when using an entirely custom theme */ @theme prefix(tw) { --color-white: #fff; --breakpoint-sm: 640px; /* … */ } ``` This will configure Tailwind CSS to require a prefix on all utility classes when used in HTML: ```html <div class="tw:bg-white tw:p-4"> This will have a white background and 4 units of padding. </div> <div class="bg-white p-4"> This will not because the prefix is missing. </div> ``` and when used in CSS via `@apply`: ```css .my-class { @apply tw:bg-white tw:p-4; } ``` Additionally, the prefix will be added to the generated CSS variables. You **do not** need to prefix the variables in the `@theme` block yourself — Tailwind CSS handles this automatically. ```css :root { --tw-color-white: #fff; --tw-breakpoint-sm: 640px; } ``` A prefix is not necessary when using the `theme(…)` function in your CSS or JS given that plugins will not know what the current prefix is and must work with or without a prefix: ```css .my-class { color: theme(--color-white); } ``` However, because the variables themselves are prefixed when outputting the CSS, you **do** need to prefix the variables when using `var(…)` in your CSS: ```css .my-class { color: var(--tw-color-white); } ``` If you want to customize the prefix itself change `tw` to something else: ```css /* my:underline, my:hover:bg-red-500, etc… */ @import 'tailwindcss' prefix(my); ``` --------- Co-authored-by: Philipp Spiess <[email protected]>
1 parent ceae1db commit a144360

File tree

10 files changed

+597
-6
lines changed

10 files changed

+597
-6
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add support for prefixes ([#14501](https://github.com/tailwindlabs/tailwindcss/pull/14501))
13+
1014
### Fixed
1115

1216
- _Experimental_: Improve codemod output, keep CSS after last Tailwind directive unlayered ([#14512](https://github.com/tailwindlabs/tailwindcss/pull/14512))

packages/tailwindcss/src/candidate.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@ import { Variants } from './variants'
66

77
function run(
88
candidate: string,
9-
{ utilities, variants }: { utilities?: Utilities; variants?: Variants } = {},
9+
{
10+
utilities,
11+
variants,
12+
prefix,
13+
}: { utilities?: Utilities; variants?: Variants; prefix?: string } = {},
1014
) {
1115
utilities ??= new Utilities()
1216
variants ??= new Variants()
1317

1418
let designSystem = buildDesignSystem(new Theme())
19+
designSystem.theme.prefix = prefix ?? null
1520

1621
designSystem.utilities = utilities
1722
designSystem.variants = variants
@@ -1259,3 +1264,46 @@ it('should parse a variant containing an arbitrary string with unbalanced parens
12591264
]
12601265
`)
12611266
})
1267+
1268+
it('should parse candidates with a prefix', () => {
1269+
let utilities = new Utilities()
1270+
utilities.static('flex', () => [])
1271+
1272+
let variants = new Variants()
1273+
variants.static('hover', () => {})
1274+
1275+
// A prefix is required
1276+
expect(run(`flex`, { utilities, variants, prefix: 'tw' })).toEqual([])
1277+
1278+
// The prefix always comes first — even before variants
1279+
expect(run(`tw:flex`, { utilities, variants, prefix: 'tw' })).toMatchInlineSnapshot(`
1280+
[
1281+
{
1282+
"important": false,
1283+
"kind": "static",
1284+
"negative": false,
1285+
"raw": "tw:flex",
1286+
"root": "flex",
1287+
"variants": [],
1288+
},
1289+
]
1290+
`)
1291+
expect(run(`tw:hover:flex`, { utilities, variants, prefix: 'tw' })).toMatchInlineSnapshot(`
1292+
[
1293+
{
1294+
"important": false,
1295+
"kind": "static",
1296+
"negative": false,
1297+
"raw": "tw:hover:flex",
1298+
"root": "flex",
1299+
"variants": [
1300+
{
1301+
"compounds": true,
1302+
"kind": "static",
1303+
"root": "hover",
1304+
},
1305+
],
1306+
},
1307+
]
1308+
`)
1309+
})

packages/tailwindcss/src/candidate.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,15 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter
226226
// ^^^^^^^^^ -> Base
227227
let rawVariants = segment(input, ':')
228228

229+
// A prefix is a special variant used to prefix all utilities. When present,
230+
// all utilities must start with that variant which we will then remove from
231+
// the variant list so no other part of the codebase has to know about it.
232+
if (designSystem.theme.prefix) {
233+
if (rawVariants[0] !== designSystem.theme.prefix) return null
234+
235+
rawVariants.shift()
236+
}
237+
229238
// Safety: At this point it is safe to use TypeScript's non-null assertion
230239
// operator because even if the `input` was an empty string, splitting an
231240
// empty string by `:` will always result in an array with at least one

packages/tailwindcss/src/compat/apply-compat-hooks.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { buildPluginApi, type CssPluginOptions, type Plugin } from './plugin-api
1313
import { registerScreensConfig } from './screens-config'
1414
import { registerThemeVariantOverrides } from './theme-variants'
1515

16+
const IS_VALID_PREFIX = /^[a-z]+$/
17+
1618
export async function applyCompatibilityHooks({
1719
designSystem,
1820
base,
@@ -208,6 +210,25 @@ export async function applyCompatibilityHooks({
208210
registerThemeVariantOverrides(resolvedUserConfig, designSystem)
209211
registerScreensConfig(resolvedUserConfig, designSystem)
210212

213+
// If a prefix has already been set in CSS don't override it
214+
if (!designSystem.theme.prefix && resolvedConfig.prefix) {
215+
if (resolvedConfig.prefix.endsWith('-')) {
216+
resolvedConfig.prefix = resolvedConfig.prefix.slice(0, -1)
217+
218+
console.warn(
219+
`The prefix "${resolvedConfig.prefix}" is invalid. Prefixes must be lowercase ASCII letters (a-z) only and is written as a variant before all utilities. We have fixed up the prefix for you. Remove the trailing \`-\` to silence this warning.`,
220+
)
221+
}
222+
223+
if (!IS_VALID_PREFIX.test(resolvedConfig.prefix)) {
224+
throw new Error(
225+
`The prefix "${resolvedConfig.prefix}" is invalid. Prefixes must be lowercase ASCII letters (a-z) only.`,
226+
)
227+
}
228+
229+
designSystem.theme.prefix = resolvedConfig.prefix
230+
}
231+
211232
// Replace `resolveThemeValue` with a version that is backwards compatible
212233
// with dot-notation but also aware of any JS theme configurations registered
213234
// by plugins or JS config files. This is significantly slower than just

packages/tailwindcss/src/compat/config.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1217,3 +1217,157 @@ test('merges css breakpoints with js config screens', async () => {
12171217
"
12181218
`)
12191219
})
1220+
1221+
test('utilities must be prefixed', async () => {
1222+
let input = css`
1223+
@tailwind utilities;
1224+
@config "./config.js";
1225+
1226+
@utility custom {
1227+
color: red;
1228+
}
1229+
`
1230+
1231+
let compiler = await compile(input, {
1232+
loadModule: async (id, base) => ({
1233+
base,
1234+
module: { prefix: 'tw' },
1235+
}),
1236+
})
1237+
1238+
// Prefixed utilities are generated
1239+
expect(compiler.build(['tw:underline', 'tw:hover:line-through', 'tw:custom']))
1240+
.toMatchInlineSnapshot(`
1241+
".tw\\:custom {
1242+
color: red;
1243+
}
1244+
.tw\\:underline {
1245+
text-decoration-line: underline;
1246+
}
1247+
.tw\\:hover\\:line-through {
1248+
&:hover {
1249+
@media (hover: hover) {
1250+
text-decoration-line: line-through;
1251+
}
1252+
}
1253+
}
1254+
"
1255+
`)
1256+
1257+
// Non-prefixed utilities are ignored
1258+
compiler = await compile(input, {
1259+
loadModule: async (id, base) => ({
1260+
base,
1261+
module: { prefix: 'tw' },
1262+
}),
1263+
})
1264+
1265+
expect(compiler.build(['underline', 'hover:line-through', 'custom'])).toEqual('')
1266+
})
1267+
1268+
test('utilities used in @apply must be prefixed', async () => {
1269+
let compiler = await compile(
1270+
css`
1271+
@config "./config.js";
1272+
1273+
.my-underline {
1274+
@apply tw:underline;
1275+
}
1276+
`,
1277+
{
1278+
loadModule: async (id, base) => ({
1279+
base,
1280+
module: { prefix: 'tw' },
1281+
}),
1282+
},
1283+
)
1284+
1285+
// Prefixed utilities are generated
1286+
expect(compiler.build([])).toMatchInlineSnapshot(`
1287+
".my-underline {
1288+
text-decoration-line: underline;
1289+
}
1290+
"
1291+
`)
1292+
1293+
// Non-prefixed utilities cause an error
1294+
expect(() =>
1295+
compile(
1296+
css`
1297+
@config "./config.js";
1298+
1299+
.my-underline {
1300+
@apply underline;
1301+
}
1302+
`,
1303+
{
1304+
loadModule: async (id, base) => ({
1305+
base,
1306+
module: { prefix: 'tw' },
1307+
}),
1308+
},
1309+
),
1310+
).rejects.toThrowErrorMatchingInlineSnapshot(
1311+
`[Error: Cannot apply unknown utility class: underline]`,
1312+
)
1313+
})
1314+
1315+
test('Prefixes configured in CSS take precedence over those defined in JS configs', async () => {
1316+
let compiler = await compile(
1317+
css`
1318+
@theme prefix(wat) {
1319+
--color-red: #f00;
1320+
--color-green: #0f0;
1321+
--breakpoint-sm: 640px;
1322+
}
1323+
1324+
@config "./plugin.js";
1325+
1326+
@tailwind utilities;
1327+
1328+
@utility custom {
1329+
color: red;
1330+
}
1331+
`,
1332+
{
1333+
async loadModule(id, base) {
1334+
return {
1335+
base,
1336+
module: { prefix: 'tw' },
1337+
}
1338+
},
1339+
},
1340+
)
1341+
1342+
expect(compiler.build(['wat:custom'])).toMatchInlineSnapshot(`
1343+
":root {
1344+
--wat-color-red: #f00;
1345+
--wat-color-green: #0f0;
1346+
--wat-breakpoint-sm: 640px;
1347+
}
1348+
.wat\\:custom {
1349+
color: red;
1350+
}
1351+
"
1352+
`)
1353+
})
1354+
1355+
test('a prefix must be letters only', async () => {
1356+
await expect(() =>
1357+
compile(
1358+
css`
1359+
@config "./plugin.js";
1360+
`,
1361+
{
1362+
async loadModule(id, base) {
1363+
return {
1364+
base,
1365+
module: { prefix: '__' },
1366+
}
1367+
},
1368+
},
1369+
),
1370+
).rejects.toThrowErrorMatchingInlineSnapshot(
1371+
`[Error: The prefix "__" is invalid. Prefixes must be lowercase ASCII letters (a-z) only.]`,
1372+
)
1373+
})

packages/tailwindcss/src/compat/config/resolve-config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ interface ResolutionContext {
2727
}
2828

2929
let minimal: ResolvedConfig = {
30+
prefix: '',
3031
darkMode: null,
3132
theme: {},
3233
plugins: [],
@@ -54,11 +55,15 @@ export function resolveConfig(design: DesignSystem, files: ConfigFile[]): Resolv
5455
extractConfigs(ctx, file)
5556
}
5657

57-
// Merge dark mode
58+
// Merge top level keys
5859
for (let config of ctx.configs) {
5960
if ('darkMode' in config && config.darkMode !== undefined) {
6061
ctx.result.darkMode = config.darkMode ?? null
6162
}
63+
64+
if ('prefix' in config && config.prefix !== undefined) {
65+
ctx.result.prefix = config.prefix ?? ''
66+
}
6267
}
6368

6469
// Merge themes

packages/tailwindcss/src/compat/config/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,12 @@ export interface UserConfig {
6969
export interface ResolvedConfig {
7070
darkMode: DarkModeStrategy | null
7171
}
72+
73+
// `prefix` support
74+
export interface UserConfig {
75+
prefix?: string
76+
}
77+
78+
export interface ResolvedConfig {
79+
prefix: string
80+
}

0 commit comments

Comments
 (0)