Skip to content

Commit f0e9343

Browse files
Add support for defining simple custom utilities in CSS via @utility (#14044)
This PR allows you to add custom utilities to your project via the new `@utility` rule. For example, given the following: ```css @Utility text-trim { text-box-trim: both; text-box-edge: cap alphabetic; } ``` A new `text-trim` utility is available and can be used directly or with variants: ```html <div class="text-trim">...</div> <div class="hover:text-trim">...</div> <div class="lg:dark:text-trim">...</div> ``` If a utility is defined more than one time the latest definition will be used: ```css @Utility text-trim { text-box-trim: both; text-box-edge: cap alphabetic; } @Utility text-trim { text-box-trim: both; text-box-edge: cap ideographic; } ``` Then using `text-trim` will produce the following CSS: ```css .text-trim { text-box-trim: both; text-box-edge: cap ideographic; } ``` You may also override specific existing utilities with this — for example to add a `text-rendering` property to the `text-sm` utility: ```css @Utility text-sm { font-size: var(--font-size-sm, 0.875rem); line-height: var(--font-size-sm--line-height, 1.25rem); text-rendering: optimizeLegibility; } ``` Though it's preferred, should you not need to add properties, to override the theme instead. Lastly, utilities with special characters do not need to be escaped like you would for a class name in a selector: ```css @Utility push-1/2 { right: 50%; } ``` We do however explicitly error on certain patterns that we want to reserve for future use, for example `push-*` and `push-[15px]`. --------- Co-authored-by: Adam Wathan <[email protected]> Co-authored-by: Adam Wathan <[email protected]>
1 parent 300524b commit f0e9343

File tree

7 files changed

+319
-111
lines changed

7 files changed

+319
-111
lines changed

CHANGELOG.md

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

2323
- Add support for basic `addVariant` plugins with new `@plugin` directive ([#13982](https://github.com/tailwindlabs/tailwindcss/pull/13982), [#14008](https://github.com/tailwindlabs/tailwindcss/pull/14008))
2424
- Add `@variant` at-rule for defining custom variants in CSS ([#13992](https://github.com/tailwindlabs/tailwindcss/pull/13992), [#14008](https://github.com/tailwindlabs/tailwindcss/pull/14008))
25+
- Add `@utility` at-rule for defining custom utilities in CSS ([#14044](https://github.com/tailwindlabs/tailwindcss/pull/14044))
2526

2627
## [4.0.0-alpha.17] - 2024-07-04
2728

packages/tailwindcss/src/candidate.ts

Lines changed: 32 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,25 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
249249
base = base.slice(1)
250250
}
251251

252+
// Candidates that start with a dash are the negative versions of another
253+
// candidate, e.g. `-mx-4`.
254+
if (base[0] === '-') {
255+
negative = true
256+
base = base.slice(1)
257+
}
258+
259+
// Check for an exact match of a static utility first as long as it does not
260+
// look like an arbitrary value.
261+
if (designSystem.utilities.has(base, 'static') && !base.includes('[')) {
262+
return {
263+
kind: 'static',
264+
root: base,
265+
variants: parsedCandidateVariants,
266+
negative,
267+
important,
268+
}
269+
}
270+
252271
// Figure out the new base and the modifier segment if present.
253272
//
254273
// E.g.:
@@ -307,13 +326,6 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
307326
}
308327
}
309328

310-
// Candidates that start with a dash are the negative versions of another
311-
// candidate, e.g. `-mx-4`.
312-
if (baseWithoutModifier[0] === '-') {
313-
negative = true
314-
baseWithoutModifier = baseWithoutModifier.slice(1)
315-
}
316-
317329
// The root of the utility, e.g.: `bg-red-500`
318330
// ^^
319331
let root: string | null = null
@@ -345,28 +357,16 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
345357

346358
// The root of the utility should exist as-is in the utilities map. If not,
347359
// it's an invalid utility and we can skip continue parsing.
348-
if (!designSystem.utilities.has(root)) return null
360+
if (!designSystem.utilities.has(root, 'functional')) return null
349361

350362
value = baseWithoutModifier.slice(idx + 1)
351363
}
352364

353365
// Not an arbitrary value
354366
else {
355-
;[root, value] = findRoot(baseWithoutModifier, designSystem.utilities)
356-
}
357-
358-
// If the root is null, but it contains a `/`, then it could be that we are
359-
// dealing with a functional utility that contains a modifier but doesn't
360-
// contain a value.
361-
//
362-
// E.g.: `@container/parent`
363-
if (root === null && base.includes('/')) {
364-
let [rootWithoutModifier, rootModifierSegment = null] = segment(base, '/')
365-
366-
modifierSegment = rootModifierSegment
367-
368-
// Try to find the root and value, without the modifier present
369-
;[root, value] = findRoot(rootWithoutModifier, designSystem.utilities)
367+
;[root, value] = findRoot(baseWithoutModifier, (root: string) => {
368+
return designSystem.utilities.has(root, 'functional')
369+
})
370370
}
371371

372372
// If there's no root, the candidate isn't a valid class and can be discarded.
@@ -377,24 +377,6 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
377377
// can skip any further parsing.
378378
if (value === '') return null
379379

380-
let kind = designSystem.utilities.kind(root)
381-
382-
if (kind === 'static') {
383-
// Static utilities do not have a value
384-
if (value !== null) return null
385-
386-
// Static utilities do not have a modifier
387-
if (modifierSegment !== null) return null
388-
389-
return {
390-
kind: 'static',
391-
root,
392-
variants: parsedCandidateVariants,
393-
negative,
394-
important,
395-
}
396-
}
397-
398380
let candidate: Candidate = {
399381
kind: 'functional',
400382
root,
@@ -560,7 +542,9 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
560542
// - `group-hover/foo/bar`
561543
if (additionalModifier) return null
562544

563-
let [root, value] = findRoot(variantWithoutModifier, designSystem.variants)
545+
let [root, value] = findRoot(variantWithoutModifier, (root) => {
546+
return designSystem.variants.has(root)
547+
})
564548

565549
// Variant is invalid, therefore the candidate is invalid and we can skip
566550
// continue parsing it.
@@ -629,26 +613,25 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
629613

630614
function findRoot(
631615
input: string,
632-
lookup: { has: (input: string) => boolean },
616+
exists: (input: string) => boolean,
633617
): [string | null, string | null] {
634-
// If the lookup has an exact match, then that's the root.
635-
if (lookup.has(input)) return [input, null]
618+
// If there is an exact match, then that's the root.
619+
if (exists(input)) return [input, null]
636620

637621
// Otherwise test every permutation of the input by iteratively removing
638622
// everything after the last dash.
639623
let idx = input.lastIndexOf('-')
640624
if (idx === -1) {
641625
// Variants starting with `@` are special because they don't need a `-`
642626
// after the `@` (E.g.: `@-lg` should be written as `@lg`).
643-
if (input[0] === '@' && lookup.has('@')) {
627+
if (input[0] === '@' && exists('@')) {
644628
return ['@', input.slice(1)]
645629
}
646630

647631
return [null, null]
648632
}
649633

650-
// Determine the root and value by testing permutations of the incoming input
651-
// against the lookup table.
634+
// Determine the root and value by testing permutations of the incoming input.
652635
//
653636
// In case of a candidate like `bg-red-500`, this looks like:
654637
//
@@ -658,7 +641,7 @@ function findRoot(
658641
do {
659642
let maybeRoot = input.slice(0, idx)
660643

661-
if (lookup.has(maybeRoot)) {
644+
if (exists(maybeRoot)) {
662645
return [maybeRoot, input.slice(idx + 1)]
663646
}
664647

packages/tailwindcss/src/compile.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,20 @@ export function compileAstNodes(rawCandidate: string, designSystem: DesignSystem
117117

118118
// Handle named utilities
119119
else if (candidate.kind === 'static' || candidate.kind === 'functional') {
120-
// Safety: At this point it is safe to use TypeScript's non-null assertion
121-
// operator because if the `candidate.root` didn't exist, `parseCandidate`
122-
// would have returned `null` and we would have returned early resulting
123-
// in not hitting this code path.
124-
let { compileFn } = designSystem.utilities.get(candidate.root)!
120+
let fns = designSystem.utilities.get(candidate.root)
125121

126122
// Build the node
127-
let compiledNodes = compileFn(candidate)
123+
let compiledNodes: AstNode[] | undefined
124+
125+
for (let i = fns.length - 1; i >= 0; i--) {
126+
let fn = fns[i]
127+
128+
if (candidate.kind !== fn.kind) continue
129+
130+
compiledNodes = fn.compileFn(candidate)
131+
if (compiledNodes) break
132+
}
133+
128134
if (compiledNodes === undefined) return null
129135

130136
nodes = compiledNodes

packages/tailwindcss/src/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ import { buildDesignSystem, type DesignSystem } from './design-system'
1717
import { Theme } from './theme'
1818
import { segment } from './utils/segment'
1919

20+
const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/
21+
2022
type PluginAPI = {
2123
addVariant(name: string, variant: string | string[] | CssInJs): void
2224
}
25+
2326
type Plugin = (api: PluginAPI) => void
2427

2528
type CompileOptions = {
@@ -52,6 +55,7 @@ export function compile(
5255
let theme = new Theme()
5356
let plugins: Plugin[] = []
5457
let customVariants: ((designSystem: DesignSystem) => void)[] = []
58+
let customUtilities: ((designSystem: DesignSystem) => void)[] = []
5559
let firstThemeRule: Rule | null = null
5660
let keyframesRules: Rule[] = []
5761

@@ -65,6 +69,33 @@ export function compile(
6569
return
6670
}
6771

72+
// Collect custom `@utility` at-rules
73+
if (node.selector.startsWith('@utility ')) {
74+
let name = node.selector.slice(9).trim()
75+
76+
if (!IS_VALID_UTILITY_NAME.test(name)) {
77+
throw new Error(
78+
`\`@utility ${name}\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.`,
79+
)
80+
}
81+
82+
if (node.nodes.length === 0) {
83+
throw new Error(
84+
`\`@utility ${name}\` is empty. Utilities should include at least one property.`,
85+
)
86+
}
87+
88+
customUtilities.push((designSystem) => {
89+
designSystem.utilities.static(name, (candidate) => {
90+
if (candidate.negative) return
91+
return structuredClone(node.nodes)
92+
})
93+
})
94+
95+
replaceWith([])
96+
return
97+
}
98+
6899
// Register custom variants from `@variant` at-rules
69100
if (node.selector.startsWith('@variant ')) {
70101
if (parent !== null) {
@@ -224,6 +255,10 @@ export function compile(
224255
customVariant(designSystem)
225256
}
226257

258+
for (let customUtility of customUtilities) {
259+
customUtility(designSystem)
260+
}
261+
227262
let api: PluginAPI = {
228263
addVariant(name, variant) {
229264
// Single selector

packages/tailwindcss/src/intellisense.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,13 @@ export type ClassEntry = [string, ClassMetadata]
1111
export function getClassList(design: DesignSystem): ClassEntry[] {
1212
let list: [string, ClassMetadata][] = []
1313

14-
for (let [utility, fn] of design.utilities.entries()) {
15-
if (typeof utility !== 'string') {
16-
continue
17-
}
18-
19-
// Static utilities only work as-is
20-
if (fn.kind === 'static') {
21-
list.push([utility, { modifiers: [] }])
22-
continue
23-
}
14+
// Static utilities only work as-is
15+
for (let utility of design.utilities.keys('static')) {
16+
list.push([utility, { modifiers: [] }])
17+
}
2418

25-
// Functional utilities have their own list of completions
19+
// Functional utilities have their own list of completions
20+
for (let utility of design.utilities.keys('functional')) {
2621
let completions = design.utilities.getCompletions(utility)
2722

2823
for (let group of completions) {

0 commit comments

Comments
 (0)