Implement --spacing(…), --alpha(…) and --theme(…) CSS functions (#15572)

This PR implements new CSS functions that you can use in your CSS (or
even in arbitrary value position).

For starters, we renamed the `theme(…)` function to `--theme(…)`. The
legacy `theme(…)` function is still available for backwards
compatibility reasons, but this allows us to be future proof since
`--foo(…)` is the syntax the CSS spec recommends. See:
https://drafts.csswg.org/css-mixins/

In addition, this PR implements a new `--spacing(…)` function, this
allows you to write:

```css
@import "tailwindcss";

@theme {
  --spacing: 0.25rem;
}

.foo {
  margin: --spacing(4):
}
```

This is syntax sugar over:
```css
@import "tailwindcss";

@theme {
  --spacing: 0.25rem;
}

.foo {
  margin: calc(var(--spacing) * 4);
}
```

If your `@theme` uses the `inline` keyword, we will also make sure to
inline the value:

```css
@import "tailwindcss";

@theme inline {
  --spacing: 0.25rem;
}

.foo {
  margin: --spacing(4):
}
```

Boils down to:
```css
@import "tailwindcss";

@theme {
  --spacing: 0.25rem;
}

.foo {
  margin: calc(0.25rem * 4); /* And will be optimised to just 1rem */
}
```

---

Another new function function we added is the `--alpha(…)` function that
requires a value, and a number / percentage value. This allows you to
apply an alpha value to any color, but with a much shorter syntax:

```css
@import "tailwindcss";

.foo {
  color: --alpha(var(--color-red-500), 0.5);
}
```

This is syntax sugar over:
```css
@import "tailwindcss";

.foo {
  color: color-mix(in oklab, var(--color-red-500) 50%, transparent);
}
```

---------

Co-authored-by: Philipp Spiess <hello@philippspiess.com>
Co-authored-by: Jordan Pittman <jordan@cryptica.me>
Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>
This commit is contained in:
Robin Malfait 2025-01-08 21:16:08 +01:00 committed by GitHub
parent ee3add9d08
commit 8d03db8178
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 312 additions and 102 deletions

View File

@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add `@tailwindcss/browser` package to run Tailwind CSS in the browser ([#15558](https://github.com/tailwindlabs/tailwindcss/pull/15558))
- Add `@reference "…"` API as a replacement for the previous `@import "…" reference` option ([#15565](https://github.com/tailwindlabs/tailwindcss/pull/15565))
- Add functional utility syntax ([#15455](https://github.com/tailwindlabs/tailwindcss/pull/15455))
- Add new `--spacing(…)`, `--alpha(…)`, and `--theme(…)` CSS functions ([#15572](https://github.com/tailwindlabs/tailwindcss/pull/15572))
### Fixed

View File

@ -253,6 +253,29 @@ function upgradeToFullPluginSupport({
userConfig,
)
// Replace `resolveThemeValue` with a version that is backwards compatible
// with dot-notation but also aware of any JS theme configurations registered
// by plugins or JS config files. This is significantly slower than just
// upgrading dot-notation keys so we only use this version if plugins or
// config files are actually being used. In the future we may want to optimize
// this further by only doing this if plugins or config files _actually_
// registered JS config objects.
designSystem.resolveThemeValue = function resolveThemeValue(path: string, defaultValue?: string) {
let resolvedValue = pluginApi.theme(path, defaultValue)
if (Array.isArray(resolvedValue) && resolvedValue.length === 2) {
// When a tuple is returned, return the first element
return resolvedValue[0]
} else if (Array.isArray(resolvedValue)) {
// Arrays get serialized into a comma-separated lists
return resolvedValue.join(', ')
} else if (typeof resolvedValue === 'string') {
// Otherwise only allow string values here, objects (and namespace maps)
// are treated as non-resolved values for the CSS `theme()` function.
return resolvedValue
}
}
let pluginApi = buildPluginApi(designSystem, ast, resolvedConfig, {
set current(value: number) {
features |= value
@ -319,29 +342,6 @@ function upgradeToFullPluginSupport({
designSystem.invalidCandidates.add(candidate)
}
// Replace `resolveThemeValue` with a version that is backwards compatible
// with dot-notation but also aware of any JS theme configurations registered
// by plugins or JS config files. This is significantly slower than just
// upgrading dot-notation keys so we only use this version if plugins or
// config files are actually being used. In the future we may want to optimize
// this further by only doing this if plugins or config files _actually_
// registered JS config objects.
designSystem.resolveThemeValue = function resolveThemeValue(path: string, defaultValue?: string) {
let resolvedValue = pluginApi.theme(path, defaultValue)
if (Array.isArray(resolvedValue) && resolvedValue.length === 2) {
// When a tuple is returned, return the first element
return resolvedValue[0]
} else if (Array.isArray(resolvedValue)) {
// Arrays get serialized into a comma-separated lists
return resolvedValue.join(', ')
} else if (typeof resolvedValue === 'string') {
// Otherwise only allow string values here, objects (and namespace maps)
// are treated as non-resolved values for the CSS `theme()` function.
return resolvedValue
}
}
for (let file of resolvedConfig.content.files) {
if ('raw' in file) {
throw new Error(

View File

@ -95,7 +95,7 @@ export function buildPluginApi(
let api: PluginAPI = {
addBase(css) {
let baseNodes = objectToAst(css)
featuresRef.current |= substituteFunctions(baseNodes, api.theme)
featuresRef.current |= substituteFunctions(baseNodes, designSystem)
ast.push(atRule('@layer', 'base', baseNodes))
},

View File

@ -62,7 +62,7 @@ export function compileCandidates(
try {
substituteFunctions(
rules.map(({ node }) => node),
designSystem.resolveThemeValue,
designSystem,
)
} catch (err) {
// If substitution fails then the candidate likely contains a call to

View File

@ -7,7 +7,168 @@ import { compileCss, optimizeCss } from './test-utils/run'
const css = String.raw
describe('theme function', () => {
describe('--alpha(…)', () => {
test('--alpha(…)', async () => {
expect(
await compileCss(css`
.foo {
margin: --alpha(red, 50%);
}
`),
).toMatchInlineSnapshot(`
".foo {
margin: oklab(62.7955% .22486 .12584 / .5);
}"
`)
})
test('--alpha(…) errors when no arguments are used', async () => {
expect(() =>
compileCss(css`
.foo {
margin: --alpha();
}
`),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: The --alpha(…) function requires two arguments, e.g.: \`--alpha(var(--my-color), 50%)\`]`,
)
})
test('--alpha(…) errors when alpha value is missing', async () => {
expect(() =>
compileCss(css`
.foo {
margin: --alpha(red);
}
`),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: The --alpha(…) function requires two arguments, e.g.: \`--alpha(red, 50%)\`]`,
)
})
test('--alpha(…) errors multiple arguments are used', async () => {
expect(() =>
compileCss(css`
.foo {
margin: --alpha(red, 50%, blue);
}
`),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: The --alpha(…) function only accepts two arguments, e.g.: \`--alpha(red, 50%)\`]`,
)
})
})
describe('--spacing(…)', () => {
test('--spacing(…)', async () => {
expect(
await compileCss(css`
@theme {
--spacing: 0.25rem;
}
.foo {
margin: --spacing(4);
}
`),
).toMatchInlineSnapshot(`
":root {
--spacing: .25rem;
}
.foo {
margin: calc(var(--spacing) * 4);
}"
`)
})
test('--spacing(…) with inline `@theme` value', async () => {
expect(
await compileCss(css`
@theme inline {
--spacing: 0.25rem;
}
.foo {
margin: --spacing(4);
}
`),
).toMatchInlineSnapshot(`
":root {
--spacing: .25rem;
}
.foo {
margin: 1rem;
}"
`)
})
test('--spacing(…) relies on `--spacing` to be defined', async () => {
expect(() =>
compileCss(css`
.foo {
margin: --spacing(4);
}
`),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: The --spacing(…) function requires that the \`--spacing\` theme variable exists, but it was not found.]`,
)
})
test('--spacing(…) requires a single value', async () => {
expect(() =>
compileCss(css`
@theme {
--spacing: 0.25rem;
}
.foo {
margin: --spacing();
}
`),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: The --spacing(…) function requires an argument, but received none.]`,
)
})
test('--spacing(…) does not have multiple arguments', async () => {
expect(() =>
compileCss(css`
.foo {
margin: --spacing(4, 5, 6);
}
`),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: The --spacing(…) function only accepts a single argument, but received 3.]`,
)
})
})
describe('--theme(…)', () => {
test('theme(--color-red-500)', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: --theme(--color-red-500);
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: red;
}"
`)
})
})
describe('theme(…)', () => {
describe('in declaration values', () => {
describe('without fallback values', () => {
test('theme(colors.red.500)', async () => {

View File

@ -1,19 +1,85 @@
import { Features } from '.'
import { walk, type AstNode } from './ast'
import type { DesignSystem } from './design-system'
import { withAlpha } from './utilities'
import { segment } from './utils/segment'
import * as ValueParser from './value-parser'
import { type ValueAstNode } from './value-parser'
export const THEME_FUNCTION_INVOCATION = 'theme('
const functions: Record<string, (designSystem: DesignSystem, ...args: string[]) => any> = {
'--alpha': alpha,
'--spacing': spacing,
'--theme': theme,
theme,
}
type ResolveThemeValue = (path: string) => string | undefined
function alpha(_designSystem: DesignSystem, value: string, alpha: string, ...rest: string[]) {
if (!value || !alpha) {
throw new Error(
`The --alpha(…) function requires two arguments, e.g.: \`--alpha(${value || 'var(--my-color)'}, ${alpha || '50%'})\``,
)
}
export function substituteFunctions(ast: AstNode[], resolveThemeValue: ResolveThemeValue) {
if (rest.length > 0) {
throw new Error(
`The --alpha(…) function only accepts two arguments, e.g.: \`--alpha(${value || 'var(--my-color)'}, ${alpha || '50%'})\``,
)
}
return withAlpha(value, alpha)
}
function spacing(designSystem: DesignSystem, value: string, ...rest: string[]) {
if (!value) {
throw new Error(`The --spacing(…) function requires an argument, but received none.`)
}
if (rest.length > 0) {
throw new Error(
`The --spacing(…) function only accepts a single argument, but received ${rest.length + 1}.`,
)
}
let multiplier = designSystem.theme.resolve(null, ['--spacing'])
if (!multiplier) {
throw new Error(
'The --spacing(…) function requires that the `--spacing` theme variable exists, but it was not found.',
)
}
return `calc(${multiplier} * ${value})`
}
function theme(designSystem: DesignSystem, path: string, ...fallback: string[]) {
path = eventuallyUnquote(path)
let resolvedValue = designSystem.resolveThemeValue(path)
if (!resolvedValue && fallback.length > 0) {
return fallback.join(', ')
}
if (!resolvedValue) {
throw new Error(
`Could not resolve value for theme function: \`theme(${path})\`. Consider checking if the path is correct or provide a fallback value to silence this error.`,
)
}
return resolvedValue
}
export const THEME_FUNCTION_INVOCATION = new RegExp(
Object.keys(functions)
.map((x) => `${x}\\(`)
.join('|'),
)
export function substituteFunctions(ast: AstNode[], designSystem: DesignSystem) {
let features = Features.None
walk(ast, (node) => {
// Find all declaration values
if (node.kind === 'declaration' && node.value?.includes(THEME_FUNCTION_INVOCATION)) {
if (node.kind === 'declaration' && node.value && THEME_FUNCTION_INVOCATION.test(node.value)) {
features |= Features.ThemeFunction
node.value = substituteFunctionsInValue(node.value, resolveThemeValue)
node.value = substituteFunctionsInValue(node.value, designSystem)
return
}
@ -24,91 +90,29 @@ export function substituteFunctions(ast: AstNode[], resolveThemeValue: ResolveTh
node.name === '@custom-media' ||
node.name === '@container' ||
node.name === '@supports') &&
node.params.includes(THEME_FUNCTION_INVOCATION)
THEME_FUNCTION_INVOCATION.test(node.params)
) {
features |= Features.ThemeFunction
node.params = substituteFunctionsInValue(node.params, resolveThemeValue)
node.params = substituteFunctionsInValue(node.params, designSystem)
}
}
})
return features
}
export function substituteFunctionsInValue(
value: string,
resolveThemeValue: ResolveThemeValue,
): string {
export function substituteFunctionsInValue(value: string, designSystem: DesignSystem): string {
let ast = ValueParser.parse(value)
ValueParser.walk(ast, (node, { replaceWith }) => {
if (node.kind === 'function' && node.value === 'theme') {
if (node.nodes.length < 1) {
throw new Error(
'Expected `theme()` function call to have a path. For example: `theme(--color-red-500)`.',
)
}
// Ignore whitespace before the first argument
if (node.nodes[0].kind === 'separator' && node.nodes[0].value.trim() === '') {
node.nodes.shift()
}
let pathNode = node.nodes[0]
if (pathNode.kind !== 'word') {
throw new Error(
`Expected \`theme()\` function to start with a path, but instead found ${pathNode.value}.`,
)
}
let path = pathNode.value
// For the theme function arguments, we require all separators to contain
// comma (`,`), spaces alone should be merged into the previous word to
// avoid splitting in this case:
//
// theme(--color-red-500 / 75%) theme(--color-red-500 / 75%, foo, bar)
//
// We only need to do this for the first node, as the fallback values are
// passed through as-is.
let skipUntilIndex = 1
for (let i = skipUntilIndex; i < node.nodes.length; i++) {
if (node.nodes[i].value.includes(',')) {
break
}
path += ValueParser.toCss([node.nodes[i]])
skipUntilIndex = i + 1
}
path = eventuallyUnquote(path)
let fallbackValues = node.nodes.slice(skipUntilIndex + 1)
replaceWith(cssThemeFn(resolveThemeValue, path, fallbackValues))
if (node.kind === 'function' && node.value in functions) {
let args = segment(ValueParser.toCss(node.nodes).trim(), ',').map((x) => x.trim())
let result = functions[node.value as keyof typeof functions](designSystem, ...args)
return replaceWith(ValueParser.parse(result))
}
})
return ValueParser.toCss(ast)
}
function cssThemeFn(
resolveThemeValue: ResolveThemeValue,
path: string,
fallbackValues: ValueAstNode[],
): ValueAstNode[] {
let resolvedValue = resolveThemeValue(path)
if (!resolvedValue && fallbackValues.length > 0) {
return fallbackValues
}
if (!resolvedValue) {
throw new Error(
`Could not resolve value for theme function: \`theme(${path})\`. Consider checking if the path is correct or provide a fallback value to silence this error.`,
)
}
// We need to parse the values recursively since this can resolve with another
// `theme()` function definition.
return ValueParser.parse(resolvedValue)
}
function eventuallyUnquote(value: string) {
if (value[0] !== "'" && value[0] !== '"') return value

View File

@ -533,7 +533,7 @@ async function parseCss(
node.context = {}
}
features |= substituteFunctions(ast, designSystem.resolveThemeValue)
features |= substituteFunctions(ast, designSystem)
features |= substituteAtApply(ast, designSystem)
// Remove `@utility`, we couldn't replace it before yet because we had to

View File

@ -17989,6 +17989,50 @@ describe('custom utilities', () => {
`)
})
test('using `--spacing(…)` shorthand', async () => {
let input = css`
@theme {
--spacing: 4px;
}
@utility example-* {
margin: --spacing(--value(number));
}
@tailwind utilities;
`
expect(await compileCss(input, ['example-12'])).toMatchInlineSnapshot(`
":root {
--spacing: 4px;
}
.example-12 {
margin: calc(var(--spacing) * 12);
}"
`)
})
test('using `--spacing(…)` shorthand (inline theme)', async () => {
let input = css`
@theme inline reference {
--spacing: 4px;
}
@utility example-* {
margin: --spacing(--value(number));
}
@tailwind utilities;
`
expect(await compileCss(input, ['example-12'])).toMatchInlineSnapshot(`
".example-12 {
margin: 48px;
}"
`)
})
test('modifiers', async () => {
let input = css`
@theme reference {