diff --git a/.changeset/thin-panthers-sing.md b/.changeset/thin-panthers-sing.md
new file mode 100644
index 000000000000..223de002c605
--- /dev/null
+++ b/.changeset/thin-panthers-sing.md
@@ -0,0 +1,5 @@
+---
+'svelte': minor
+---
+
+feat: allow `class` attribute to be an object or array, using `clsx`
diff --git a/documentation/docs/03-template-syntax/16-class.md b/documentation/docs/03-template-syntax/16-class.md
deleted file mode 100644
index cecbc7cf2041..000000000000
--- a/documentation/docs/03-template-syntax/16-class.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-title: class:
----
-
-The `class:` directive is a convenient way to conditionally set classes on elements, as an alternative to using conditional expressions inside `class` attributes:
-
-```svelte
-
-
...
-
...
-```
-
-As with other directives, we can use a shorthand when the name of the class coincides with the value:
-
-```svelte
-
...
-```
-
-Multiple `class:` directives can be added to a single element:
-
-```svelte
-
...
-```
diff --git a/documentation/docs/03-template-syntax/18-class.md b/documentation/docs/03-template-syntax/18-class.md
new file mode 100644
index 000000000000..880a34e9ec53
--- /dev/null
+++ b/documentation/docs/03-template-syntax/18-class.md
@@ -0,0 +1,90 @@
+---
+title: class
+---
+
+There are two ways to set classes on elements: the `class` attribute, and the `class:` directive.
+
+## Attributes
+
+Primitive values are treated like any other attribute:
+
+```svelte
+
...
+```
+
+> [!NOTE]
+> For historical reasons, falsy values (like `false` and `NaN`) are stringified (`class="false"`), though `class={undefined}` (or `null`) cause the attribute to be omitted altogether. In a future version of Svelte, all falsy values will cause `class` to be omitted.
+
+### Objects and arrays
+
+Since Svelte 5.16, `class` can be an object or array, and is converted to a string using [clsx](https://github.com/lukeed/clsx).
+
+If the value is an object, the truthy keys are added:
+
+```svelte
+
+
+
+
...
+```
+
+If the value is an array, the truthy values are combined:
+
+```svelte
+
+
...
+```
+
+Note that whether we're using the array or object form, we can set multiple classes simultaneously with a single condition, which is particularly useful if you're using things like Tailwind.
+
+Arrays can contain arrays and objects, and clsx will flatten them. This is useful for combining local classes with props, for example:
+
+```svelte
+
+
+
+
+```
+
+The user of this component has the same flexibility to use a mixture of objects, arrays and strings:
+
+```svelte
+
+
+
+
+```
+
+## The `class:` directive
+
+Prior to Svelte 5.16, the `class:` directive was the most convenient way to set classes on elements conditionally.
+
+```svelte
+
+
...
+
...
+```
+
+As with other directives, we can use a shorthand when the name of the class coincides with the value:
+
+```svelte
+
...
+```
+
+> [!NOTE] Unless you're using an older version of Svelte, consider avoiding `class:`, since the attribute is more powerful and composable.
diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts
index 8800b65172dc..604403f0a261 100644
--- a/packages/svelte/elements.d.ts
+++ b/packages/svelte/elements.d.ts
@@ -741,7 +741,7 @@ export interface HTMLAttributes extends AriaAttributes, D
accesskey?: string | undefined | null;
autocapitalize?: 'characters' | 'off' | 'on' | 'none' | 'sentences' | 'words' | undefined | null;
autofocus?: boolean | undefined | null;
- class?: string | undefined | null;
+ class?: string | import('clsx').ClassArray | import('clsx').ClassDictionary | undefined | null;
contenteditable?: Booleanish | 'inherit' | 'plaintext-only' | undefined | null;
contextmenu?: string | undefined | null;
dir?: 'ltr' | 'rtl' | 'auto' | undefined | null;
@@ -1522,7 +1522,7 @@ export interface SvelteWindowAttributes extends HTMLAttributes {
export interface SVGAttributes extends AriaAttributes, DOMAttributes {
// Attributes which also defined in HTMLAttributes
className?: string | undefined | null;
- class?: string | undefined | null;
+ class?: string | import('clsx').ClassArray | import('clsx').ClassDictionary | undefined | null;
color?: string | undefined | null;
height?: number | string | undefined | null;
id?: string | undefined | null;
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index 05169a7bc2ab..b9f4d3de0951 100644
--- a/packages/svelte/package.json
+++ b/packages/svelte/package.json
@@ -153,6 +153,7 @@
"acorn-typescript": "^1.4.13",
"aria-query": "^5.3.1",
"axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
"esm-env": "^1.2.1",
"esrap": "^1.3.2",
"is-reference": "^3.0.3",
diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js
index 35bc675166ae..ca7476ef7fc1 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js
@@ -731,7 +731,7 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv
/** @type {string[]} */
let prev_values = [];
for (const chunk of chunks) {
- const current_possible_values = get_possible_values(chunk);
+ const current_possible_values = get_possible_values(chunk, name === 'class');
// impossible to find out all combinations
if (!current_possible_values) return true;
@@ -784,7 +784,7 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv
prev_values.push(current_possible_value);
}
});
- if (prev_values.length < current_possible_values.size) {
+ if (prev_values.length < current_possible_values.length) {
prev_values.push(' ');
}
if (prev_values.length > 20) {
diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/utils.js b/packages/svelte/src/compiler/phases/2-analyze/css/utils.js
index d3fd71ec395b..d7544b55c063 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/css/utils.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/css/utils.js
@@ -4,14 +4,74 @@ const UNKNOWN = {};
/**
* @param {Node} node
+ * @param {boolean} is_class
* @param {Set} set
+ * @param {boolean} is_nested
*/
-function gather_possible_values(node, set) {
+function gather_possible_values(node, is_class, set, is_nested = false) {
+ if (set.has(UNKNOWN)) {
+ // no point traversing any further
+ return;
+ }
+
if (node.type === 'Literal') {
set.add(String(node.value));
} else if (node.type === 'ConditionalExpression') {
- gather_possible_values(node.consequent, set);
- gather_possible_values(node.alternate, set);
+ gather_possible_values(node.consequent, is_class, set, is_nested);
+ gather_possible_values(node.alternate, is_class, set, is_nested);
+ } else if (node.type === 'LogicalExpression') {
+ if (node.operator === '&&') {
+ // && is a special case, because the only way the left
+ // hand value can be included is if it's falsy. this is
+ // a bit of extra work but it's worth it because
+ // `class={[condition && 'blah']}` is common,
+ // and we don't want to deopt on `condition`
+ const left = new Set();
+ gather_possible_values(node.left, is_class, left, is_nested);
+
+ if (left.has(UNKNOWN)) {
+ // add all non-nullish falsy values, unless this is a `class` attribute that
+ // will be processed by cslx, in which case falsy values are removed, unless
+ // they're not inside an array/object (TODO 6.0 remove that last part)
+ if (!is_class || !is_nested) {
+ set.add('');
+ set.add(false);
+ set.add(NaN);
+ set.add(0); // -0 and 0n are also falsy, but stringify to '0'
+ }
+ } else {
+ for (const value of left) {
+ if (!value && value != undefined && (!is_class || !is_nested)) {
+ set.add(value);
+ }
+ }
+ }
+
+ gather_possible_values(node.right, is_class, set, is_nested);
+ } else {
+ gather_possible_values(node.left, is_class, set, is_nested);
+ gather_possible_values(node.right, is_class, set, is_nested);
+ }
+ } else if (is_class && node.type === 'ArrayExpression') {
+ for (const entry of node.elements) {
+ if (entry) {
+ gather_possible_values(entry, is_class, set, true);
+ }
+ }
+ } else if (is_class && node.type === 'ObjectExpression') {
+ for (const property of node.properties) {
+ if (
+ property.type === 'Property' &&
+ !property.computed &&
+ (property.key.type === 'Identifier' || property.key.type === 'Literal')
+ ) {
+ set.add(
+ property.key.type === 'Identifier' ? property.key.name : String(property.key.value)
+ );
+ } else {
+ set.add(UNKNOWN);
+ }
+ }
} else {
set.add(UNKNOWN);
}
@@ -19,19 +79,20 @@ function gather_possible_values(node, set) {
/**
* @param {AST.Text | AST.ExpressionTag} chunk
- * @returns {Set | null}
+ * @param {boolean} is_class
+ * @returns {string[] | null}
*/
-export function get_possible_values(chunk) {
+export function get_possible_values(chunk, is_class) {
const values = new Set();
if (chunk.type === 'Text') {
values.add(chunk.data);
} else {
- gather_possible_values(chunk.expression, values);
+ gather_possible_values(chunk.expression, is_class, values);
}
if (values.has(UNKNOWN)) return null;
- return values;
+ return [...values].map((value) => String(value));
}
/**
diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js
index c0e4a655712c..76c1e94277be 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/index.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/index.js
@@ -773,6 +773,8 @@ export function analyze_component(root, source, options) {
if (attribute.type !== 'Attribute') continue;
if (attribute.name.toLowerCase() !== 'class') continue;
+ // The dynamic class method appends the hash to the end of the class attribute on its own
+ if (attribute.metadata.needs_clsx) continue outer;
class_attribute = attribute;
}
diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js
index 6eb9faca6d4e..9d801e095e8d 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js
@@ -38,6 +38,19 @@ export function Attribute(node, context) {
mark_subtree_dynamic(context.path);
}
+ // class={[...]} or class={{...}} or `class={x}` need clsx to resolve the classes
+ if (
+ node.name === 'class' &&
+ !Array.isArray(node.value) &&
+ node.value !== true &&
+ node.value.expression.type !== 'Literal' &&
+ node.value.expression.type !== 'TemplateLiteral' &&
+ node.value.expression.type !== 'BinaryExpression'
+ ) {
+ mark_subtree_dynamic(context.path);
+ node.metadata.needs_clsx = true;
+ }
+
if (node.value !== true) {
for (const chunk of get_attribute_chunks(node.value)) {
if (chunk.type !== 'ExpressionTag') continue;
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js
index 2c2c287f1275..59a6fafbc5d7 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js
@@ -553,6 +553,10 @@ function build_element_attribute_update_assignment(
let update;
if (name === 'class') {
+ if (attribute.metadata.needs_clsx) {
+ value = b.call('$.clsx', value);
+ }
+
if (attribute.metadata.expression.has_state && has_call) {
// ensure we're not creating a separate template effect for this so that
// potential class directives are added to the same effect and therefore always apply
@@ -561,11 +565,13 @@ function build_element_attribute_update_assignment(
value = b.call('$.get', id);
has_call = false;
}
+
update = b.stmt(
b.call(
is_svg ? '$.set_svg_class' : is_mathml ? '$.set_mathml_class' : '$.set_class',
node_id,
- value
+ value,
+ attribute.metadata.needs_clsx ? b.literal(context.state.analysis.css.hash) : undefined
)
);
} else if (name === 'value') {
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js
index 5ebc6475713f..d0d800d3cbc5 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js
@@ -86,10 +86,35 @@ export function build_element_attributes(node, context) {
} else if (attribute.name !== 'defaultValue' && attribute.name !== 'defaultChecked') {
if (attribute.name === 'class') {
class_index = attributes.length;
- } else if (attribute.name === 'style') {
- style_index = attributes.length;
+
+ if (attribute.metadata.needs_clsx) {
+ const clsx_value = b.call(
+ '$.clsx',
+ /** @type {AST.ExpressionTag} */ (attribute.value).expression
+ );
+ attributes.push({
+ ...attribute,
+ value: {
+ .../** @type {AST.ExpressionTag} */ (attribute.value),
+ expression: context.state.analysis.css.hash
+ ? b.binary(
+ '+',
+ b.binary('+', clsx_value, b.literal(' ')),
+ b.literal(context.state.analysis.css.hash)
+ )
+ : clsx_value
+ }
+ });
+ } else {
+ attributes.push(attribute);
+ }
+ } else {
+ if (attribute.name === 'style') {
+ style_index = attributes.length;
+ }
+
+ attributes.push(attribute);
}
- attributes.push(attribute);
}
} else if (attribute.type === 'BindDirective') {
if (attribute.name === 'value' && node.name === 'select') continue;
diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js
index 7aa9fecd3aba..5066833feb8e 100644
--- a/packages/svelte/src/compiler/phases/nodes.js
+++ b/packages/svelte/src/compiler/phases/nodes.js
@@ -45,7 +45,8 @@ export function create_attribute(name, start, end, value) {
value,
metadata: {
expression: create_expression_metadata(),
- delegated: null
+ delegated: null,
+ needs_clsx: false
}
};
}
diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts
index 97a25df4a758..8be9aed17723 100644
--- a/packages/svelte/src/compiler/types/template.d.ts
+++ b/packages/svelte/src/compiler/types/template.d.ts
@@ -482,6 +482,8 @@ export namespace AST {
expression: ExpressionMetadata;
/** May be set if this is an event attribute */
delegated: null | DelegatedEvent;
+ /** May be `true` if this is a `class` attribute that needs `clsx` */
+ needs_clsx: boolean;
};
}
diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js
index 9c62d684c183..6656532986d7 100644
--- a/packages/svelte/src/internal/client/dom/elements/attributes.js
+++ b/packages/svelte/src/internal/client/dom/elements/attributes.js
@@ -13,6 +13,7 @@ import {
set_active_effect,
set_active_reaction
} from '../../runtime.js';
+import { clsx } from '../../../shared/attributes.js';
/**
* The value/checked attribute in the template actually corresponds to the defaultValue property, so we need
@@ -267,6 +268,10 @@ export function set_attributes(
}
}
+ if (next.class) {
+ next.class = clsx(next.class);
+ }
+
if (css_hash !== undefined) {
next.class = next.class ? next.class + ' ' + css_hash : css_hash;
}
diff --git a/packages/svelte/src/internal/client/dom/elements/class.js b/packages/svelte/src/internal/client/dom/elements/class.js
index 22f3da0f44f9..62ffb6d14b5c 100644
--- a/packages/svelte/src/internal/client/dom/elements/class.js
+++ b/packages/svelte/src/internal/client/dom/elements/class.js
@@ -3,12 +3,13 @@ import { hydrating } from '../hydration.js';
/**
* @param {SVGElement} dom
* @param {string} value
+ * @param {string} [hash]
* @returns {void}
*/
-export function set_svg_class(dom, value) {
+export function set_svg_class(dom, value, hash) {
// @ts-expect-error need to add __className to patched prototype
var prev_class_name = dom.__className;
- var next_class_name = to_class(value);
+ var next_class_name = to_class(value, hash);
if (hydrating && dom.getAttribute('class') === next_class_name) {
// In case of hydration don't reset the class as it's already correct.
@@ -32,12 +33,13 @@ export function set_svg_class(dom, value) {
/**
* @param {MathMLElement} dom
* @param {string} value
+ * @param {string} [hash]
* @returns {void}
*/
-export function set_mathml_class(dom, value) {
+export function set_mathml_class(dom, value, hash) {
// @ts-expect-error need to add __className to patched prototype
var prev_class_name = dom.__className;
- var next_class_name = to_class(value);
+ var next_class_name = to_class(value, hash);
if (hydrating && dom.getAttribute('class') === next_class_name) {
// In case of hydration don't reset the class as it's already correct.
@@ -61,12 +63,13 @@ export function set_mathml_class(dom, value) {
/**
* @param {HTMLElement} dom
* @param {string} value
+ * @param {string} [hash]
* @returns {void}
*/
-export function set_class(dom, value) {
+export function set_class(dom, value, hash) {
// @ts-expect-error need to add __className to patched prototype
var prev_class_name = dom.__className;
- var next_class_name = to_class(value);
+ var next_class_name = to_class(value, hash);
if (hydrating && dom.className === next_class_name) {
// In case of hydration don't reset the class as it's already correct.
@@ -79,7 +82,7 @@ export function set_class(dom, value) {
// Removing the attribute when the value is only an empty string causes
// peformance issues vs simply making the className an empty string. So
// we should only remove the class if the the value is nullish.
- if (value == null) {
+ if (value == null && !hash) {
dom.removeAttribute('class');
} else {
dom.className = next_class_name;
@@ -93,10 +96,11 @@ export function set_class(dom, value) {
/**
* @template V
* @param {V} value
+ * @param {string} [hash]
* @returns {string | V}
*/
-function to_class(value) {
- return value == null ? '' : value;
+function to_class(value, hash) {
+ return (value == null ? '' : value) + (hash ? ' ' + hash : '');
}
/**
diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js
index f22c33babc52..3b85ae18166e 100644
--- a/packages/svelte/src/internal/client/index.js
+++ b/packages/svelte/src/internal/client/index.js
@@ -161,7 +161,7 @@ export {
$window as window,
$document as document
} from './dom/operations.js';
-export { attr } from '../shared/attributes.js';
+export { attr, clsx } from '../shared/attributes.js';
export { snapshot } from '../shared/clone.js';
export { noop, fallback } from '../shared/utils.js';
export {
diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js
index b8371b7e008f..89b3c33df887 100644
--- a/packages/svelte/src/internal/server/index.js
+++ b/packages/svelte/src/internal/server/index.js
@@ -2,7 +2,7 @@
/** @import { Component, Payload, RenderOutput } from '#server' */
/** @import { Store } from '#shared' */
export { FILENAME, HMR } from '../../constants.js';
-import { attr } from '../shared/attributes.js';
+import { attr, clsx } from '../shared/attributes.js';
import { is_promise, noop } from '../shared/utils.js';
import { subscribe_to_store } from '../../store/utils.js';
import {
@@ -195,6 +195,10 @@ export function spread_attributes(attrs, classes, styles, flags = 0) {
: style_object_to_string(styles);
}
+ if (attrs.class) {
+ attrs.class = clsx(attrs.class);
+ }
+
if (classes) {
const classlist = attrs.class ? [attrs.class] : [];
@@ -522,7 +526,7 @@ export function once(get_value) {
};
}
-export { attr };
+export { attr, clsx };
export { html } from './blocks/html.js';
diff --git a/packages/svelte/src/internal/shared/attributes.js b/packages/svelte/src/internal/shared/attributes.js
index 867d6ba5d378..a561501bf4f6 100644
--- a/packages/svelte/src/internal/shared/attributes.js
+++ b/packages/svelte/src/internal/shared/attributes.js
@@ -1,4 +1,5 @@
import { escape_html } from '../../escaping.js';
+import { clsx as _clsx } from 'clsx';
/**
* `