Skip to content

Commit

Permalink
Implement --spacing(…), --alpha(…) and --theme(…) CSS functions (
Browse files Browse the repository at this point in the history
…#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 <[email protected]>
Co-authored-by: Jordan Pittman <[email protected]>
Co-authored-by: Jonathan Reinink <[email protected]>
  • Loading branch information
4 people authored Jan 8, 2025
1 parent ee3add9 commit 8d03db8
Show file tree
Hide file tree
Showing 8 changed files with 312 additions and 102 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 23 additions & 23 deletions packages/tailwindcss/src/compat/apply-compat-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/tailwindcss/src/compat/plugin-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
},

Expand Down
2 changes: 1 addition & 1 deletion packages/tailwindcss/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 162 additions & 1 deletion packages/tailwindcss/src/css-functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading

0 comments on commit 8d03db8

Please sign in to comment.