diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c3c14e4e..cd1132782 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add new standalone builds of Tailwind CSS v4 ([#14270](https://github.com/tailwindlabs/tailwindcss/pull/14270))
- Support JavaScript configuration files using `@config` ([#14239](https://github.com/tailwindlabs/tailwindcss/pull/14239))
+- Support plugin options in `@plugin` ([#14264](https://github.com/tailwindlabs/tailwindcss/pull/14264))
### Fixed
diff --git a/integrations/cli/plugins.test.ts b/integrations/cli/plugins.test.ts
index 0dce0c2bc..6fe04f022 100644
--- a/integrations/cli/plugins.test.ts
+++ b/integrations/cli/plugins.test.ts
@@ -77,6 +77,51 @@ test(
},
)
+test(
+ 'builds the `@tailwindcss/forms` plugin utilities (with options)',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "@tailwindcss/forms": "^0.5.7",
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/cli": "workspace:^"
+ }
+ }
+ `,
+ 'index.html': html`
+
+
+ `,
+ 'src/index.css': css`
+ @import 'tailwindcss';
+ @plugin '@tailwindcss/forms' {
+ strategy: base;
+ }
+ `,
+ },
+ },
+ async ({ fs, exec }) => {
+ await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
+
+ await fs.expectFileToContain('dist/out.css', [
+ //
+ `::-webkit-date-and-time-value`,
+ `[type='checkbox']:indeterminate`,
+ ])
+
+ // No classes are included even though they are used in the HTML
+ // because the `base` strategy is used
+ await fs.expectFileNotToContain('dist/out.css', [
+ //
+ candidate`form-input`,
+ candidate`form-textarea`,
+ candidate`form-radio`,
+ ])
+ },
+)
+
test(
'builds the `tailwindcss-animate` plugin utilities',
{
diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts
index 15dfaa942..a22097a60 100644
--- a/packages/tailwindcss/src/index.test.ts
+++ b/packages/tailwindcss/src/index.test.ts
@@ -2,6 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import { describe, expect, it, test } from 'vitest'
import { compile } from '.'
+import plugin from './plugin'
import type { PluginAPI } from './plugin-api'
import { compileCss, optimizeCss, run } from './test-utils/run'
@@ -1294,13 +1295,11 @@ describe('Parsing themes values from CSS', () => {
})
describe('plugins', () => {
- test('@plugin can not have a body.', async () =>
+ test('@plugin need a path', () =>
expect(
compile(
css`
- @plugin {
- color: red;
- }
+ @plugin;
`,
{
loadPlugin: async () => {
@@ -1310,7 +1309,23 @@ describe('plugins', () => {
},
},
),
- ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` cannot have a body.]`))
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`))
+
+ test('@plugin can not have an empty path', () =>
+ expect(
+ compile(
+ css`
+ @plugin '';
+ `,
+ {
+ loadPlugin: async () => {
+ return ({ addVariant }: PluginAPI) => {
+ addVariant('hocus', '&:hover, &:focus')
+ }
+ },
+ },
+ ),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`))
test('@plugin cannot be nested.', () =>
expect(
@@ -1330,6 +1345,206 @@ describe('plugins', () => {
),
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` cannot be nested.]`))
+ test('@plugin can accept options', async () => {
+ expect.hasAssertions()
+
+ let { build } = await compile(
+ css`
+ @tailwind utilities;
+ @plugin "my-plugin" {
+ color: red;
+ }
+ `,
+ {
+ loadPlugin: async () => {
+ return plugin.withOptions((options) => {
+ expect(options).toEqual({
+ color: 'red',
+ })
+
+ return ({ addUtilities }) => {
+ addUtilities({
+ '.text-primary': {
+ color: options.color,
+ },
+ })
+ }
+ })
+ },
+ },
+ )
+
+ let compiled = build(['text-primary'])
+
+ expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
+ ".text-primary {
+ color: red;
+ }"
+ `)
+ })
+
+ test('@plugin options can be null, booleans, string, numbers, or arrays including those types', async () => {
+ expect.hasAssertions()
+
+ await compile(
+ css`
+ @tailwind utilities;
+ @plugin "my-plugin" {
+ is-null: null;
+ is-true: true;
+ is-false: false;
+ is-int: 1234567;
+ is-float: 1.35;
+ is-sci: 1.35e-5;
+ is-str-null: 'null';
+ is-str-true: 'true';
+ is-str-false: 'false';
+ is-str-int: '1234567';
+ is-str-float: '1.35';
+ is-str-sci: '1.35e-5';
+ is-arr: foo, bar;
+ is-arr-mixed: null, true, false, 1234567, 1.35, foo, 'bar', 'true';
+ }
+ `,
+ {
+ loadPlugin: async () => {
+ return plugin.withOptions((options) => {
+ expect(options).toEqual({
+ 'is-null': null,
+ 'is-true': true,
+ 'is-false': false,
+ 'is-int': 1234567,
+ 'is-float': 1.35,
+ 'is-sci': 1.35e-5,
+ 'is-str-null': 'null',
+ 'is-str-true': 'true',
+ 'is-str-false': 'false',
+ 'is-str-int': '1234567',
+ 'is-str-float': '1.35',
+ 'is-str-sci': '1.35e-5',
+ 'is-arr': ['foo', 'bar'],
+ 'is-arr-mixed': [null, true, false, 1234567, 1.35, 'foo', 'bar', 'true'],
+ })
+
+ return () => {}
+ })
+ },
+ },
+ )
+ })
+
+ test('@plugin options can only be simple key/value pairs', () => {
+ expect(
+ compile(
+ css`
+ @plugin "my-plugin" {
+ color: red;
+ sizes {
+ sm: 1rem;
+ md: 2rem;
+ }
+ }
+ `,
+ {
+ loadPlugin: async () => {
+ return plugin.withOptions((options) => {
+ return ({ addUtilities }) => {
+ addUtilities({
+ '.text-primary': {
+ color: options.color,
+ },
+ })
+ }
+ })
+ },
+ },
+ ),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `
+ [Error: Unexpected \`@plugin\` option:
+
+ sizes {
+ sm: 1rem;
+ md: 2rem;
+ }
+
+
+ \`@plugin\` options must be a flat list of declarations.]
+ `,
+ )
+ })
+
+ test('@plugin options can only be provided to plugins using withOptions', () => {
+ expect(
+ compile(
+ css`
+ @plugin "my-plugin" {
+ color: red;
+ }
+ `,
+ {
+ loadPlugin: async () => {
+ return plugin(({ addUtilities }) => {
+ addUtilities({
+ '.text-primary': {
+ color: 'red',
+ },
+ })
+ })
+ },
+ },
+ ),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `[Error: The plugin "my-plugin" does not accept options]`,
+ )
+ })
+
+ test('@plugin errors on array-like syntax', () => {
+ expect(
+ compile(
+ css`
+ @plugin "my-plugin" {
+ --color: [ 'red', 'green', 'blue'];
+ }
+ `,
+ {
+ loadPlugin: async () => plugin(() => {}),
+ },
+ ),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `[Error: The plugin "my-plugin" does not accept options]`,
+ )
+ })
+
+ test('@plugin errors on object-like syntax', () => {
+ expect(
+ compile(
+ css`
+ @plugin "my-plugin" {
+ --color: {
+ red: 100;
+ green: 200;
+ blue: 300;
+ };
+ }
+ `,
+ {
+ loadPlugin: async () => plugin(() => {}),
+ },
+ ),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `
+ [Error: Unexpected \`@plugin\` option: Value of declaration \`--color: {
+ red: 100;
+ green: 200;
+ blue: 300;
+ };\` is not supported.
+
+ Using an object as a plugin option is currently only supported in JavaScript configuration files.]
+ `,
+ )
+ })
+
test('addVariant with string selector', async () => {
let { build } = await compile(
css`
diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts
index c7f3916c8..2af735f0b 100644
--- a/packages/tailwindcss/src/index.ts
+++ b/packages/tailwindcss/src/index.ts
@@ -6,7 +6,7 @@ import { compileCandidates } from './compile'
import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
import { substituteFunctions, THEME_FUNCTION_INVOCATION } from './functions'
-import { registerPlugins, type Plugin } from './plugin-api'
+import { registerPlugins, type CssPluginOptions, type Plugin } from './plugin-api'
import { Theme } from './theme'
import { segment } from './utils/segment'
@@ -48,7 +48,7 @@ async function parseCss(
// Find all `@theme` declarations
let theme = new Theme()
- let pluginPaths: string[] = []
+ let pluginPaths: [string, CssPluginOptions | null][] = []
let configPaths: string[] = []
let customVariants: ((designSystem: DesignSystem) => void)[] = []
let customUtilities: ((designSystem: DesignSystem) => void)[] = []
@@ -61,15 +61,60 @@ async function parseCss(
// Collect paths from `@plugin` at-rules
if (node.selector === '@plugin' || node.selector.startsWith('@plugin ')) {
- if (node.nodes.length > 0) {
- throw new Error('`@plugin` cannot have a body.')
- }
-
if (parent !== null) {
throw new Error('`@plugin` cannot be nested.')
}
- pluginPaths.push(node.selector.slice(9, -1))
+ let pluginPath = node.selector.slice(9, -1)
+ if (pluginPath.length === 0) {
+ throw new Error('`@plugin` must have a path.')
+ }
+
+ let options: CssPluginOptions = {}
+
+ for (let decl of node.nodes ?? []) {
+ if (decl.kind !== 'declaration') {
+ throw new Error(
+ `Unexpected \`@plugin\` option:\n\n${toCss([decl])}\n\n\`@plugin\` options must be a flat list of declarations.`,
+ )
+ }
+
+ if (decl.value === undefined) continue
+
+ // Parse the declaration value as a primitive type
+ // These are the same primitive values supported by JSON
+ let value: CssPluginOptions[keyof CssPluginOptions] = decl.value
+
+ let parts = segment(value, ',').map((part) => {
+ part = part.trim()
+
+ if (part === 'null') {
+ return null
+ } else if (part === 'true') {
+ return true
+ } else if (part === 'false') {
+ return false
+ } else if (!Number.isNaN(Number(part))) {
+ return Number(part)
+ } else if (
+ (part[0] === '"' && part[part.length - 1] === '"') ||
+ (part[0] === "'" && part[part.length - 1] === "'")
+ ) {
+ return part.slice(1, -1)
+ } else if (part[0] === '{' && part[part.length - 1] === '}') {
+ throw new Error(
+ `Unexpected \`@plugin\` option: Value of declaration \`${toCss([decl]).trim()}\` is not supported.\n\nUsing an object as a plugin option is currently only supported in JavaScript configuration files.`,
+ )
+ }
+
+ return part
+ })
+
+ options[decl.property] = parts.length === 1 ? parts[0] : parts
+ }
+
+ pluginPaths.push([pluginPath, Object.keys(options).length > 0 ? options : null])
+
replaceWith([])
return
}
@@ -304,7 +349,13 @@ async function parseCss(
})),
)
- let plugins = await Promise.all(pluginPaths.map(loadPlugin))
+ let plugins = await Promise.all(
+ pluginPaths.map(async ([pluginPath, pluginOptions]) => ({
+ path: pluginPath,
+ plugin: await loadPlugin(pluginPath),
+ options: pluginOptions,
+ })),
+ )
let { pluginApi, resolvedConfig } = registerPlugins(plugins, designSystem, ast, configs)
diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts
index 6f86f30b0..53732e37b 100644
--- a/packages/tailwindcss/src/plugin-api.ts
+++ b/packages/tailwindcss/src/plugin-api.ts
@@ -346,12 +346,33 @@ function objectToAst(rules: CssInJs | CssInJs[]): AstNode[] {
return ast
}
+type Primitive = string | number | boolean | null
+export type CssPluginOptions = Record
+
+interface PluginDetail {
+ path: string
+ plugin: Plugin
+ options: CssPluginOptions | null
+}
+
export function registerPlugins(
- plugins: Plugin[],
+ pluginDetails: PluginDetail[],
designSystem: DesignSystem,
ast: AstNode[],
configs: ConfigFile[],
) {
+ let plugins = pluginDetails.map((detail) => {
+ if (!detail.options) {
+ return detail.plugin
+ }
+
+ if ('__isOptionsFunction' in detail.plugin) {
+ return detail.plugin(detail.options)
+ }
+
+ throw new Error(`The plugin "${detail.path}" does not accept options`)
+ })
+
let userConfig = [{ config: { plugins } }, ...configs]
let resolvedConfig = resolveConfig(designSystem, [