diff --git a/.changeset/green-starfishes-yawn.md b/.changeset/green-starfishes-yawn.md
new file mode 100644
index 000000000000..645e7f556c0a
--- /dev/null
+++ b/.changeset/green-starfishes-yawn.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+feat: allow `style` attribute to be an object or array
diff --git a/documentation/docs/03-template-syntax/17-style.md b/documentation/docs/03-template-syntax/17-style.md
index 749376c6e2a6..226359ebb479 100644
--- a/documentation/docs/03-template-syntax/17-style.md
+++ b/documentation/docs/03-template-syntax/17-style.md
@@ -1,7 +1,75 @@
---
-title: style:
+title: style and style:
---
+There are two ways to set styles on elements: the `style` attribute, and the `style:` directive.
+
+## Attributes
+
+Primitive values are treated like any other attribute:
+
+```svelte
+
...
+```
+
+### Objects and arrays
+
+Since Svelte 5.XX, `style` can be an object or array, and is converted to a string according to the following rules :
+
+If the value is an
+
+If the value is an object, the key/value are converted to CSS properties if the value is not-null and not-empty.
+
+```svelte
+
+
...
+```
+
+> [!NOTE]
+> The CSS properties are case-insensitive and use `kebab-case`, which requires quoting key's name in JavaScript.
+> In order to avoid this, object keys will be 'converted' according to the following rules :
+> * Uppercase keys like `COLOR` will be converted to the lowercase format `color`.
+> * `camelCase` keys like `fontSize` will be converted to the kebab-case format `font-size`.
+> * `snake_case` keys like `border_color` will be converted to the kebab-case format `border-color`.
+> Note that this will not apply to key that starts with a double hyphens, because CSS variable don't have naming rules and are case-sensitive (`--myvar` is different from `--myVar`).
+> But we can use a double underscores to enable the same rules. Ex: `__myVar` or `__my_var` will be converted to `--my-var`.
+
+If the value is an array, the truthy values are combined, string are passed without change, and array/objects are flatten :
+
+```svelte
+
+
...
+```
+
+This is useful for combining local styles with props, for example:
+
+```svelte
+
+
+
+
+```
+
+
+Svelte also exposes the `StyleValue` type, which is the type of value that the `style` attribute on elements accept. This is useful if you want to use a type-safe class name in component props:
+
+```svelte
+
+
+
...
+```
+
+
+## The `style:` directive
+
The `style:` directive provides a shorthand for setting multiple styles on an element.
```svelte
diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts
index 6d256b56205c..64b51a993f71 100644
--- a/packages/svelte/elements.d.ts
+++ b/packages/svelte/elements.d.ts
@@ -764,7 +764,7 @@ export interface HTMLAttributes extends AriaAttributes, D
placeholder?: string | undefined | null;
slot?: string | undefined | null;
spellcheck?: Booleanish | undefined | null;
- style?: string | undefined | null;
+ style?: StyleValue | undefined | null;
tabindex?: number | undefined | null;
title?: string | undefined | null;
translate?: 'yes' | 'no' | '' | undefined | null;
@@ -1534,7 +1534,7 @@ export interface SVGAttributes extends AriaAttributes, DO
method?: 'align' | 'stretch' | undefined | null;
min?: number | string | undefined | null;
name?: string | undefined | null;
- style?: string | undefined | null;
+ style?: StyleValue | undefined | null;
target?: string | undefined | null;
type?: string | undefined | null;
width?: number | string | undefined | null;
@@ -2062,3 +2062,7 @@ export interface SvelteHTMLElements {
}
export type ClassValue = string | import('clsx').ClassArray | import('clsx').ClassDictionary;
+
+type StyleDictionary = Record;
+type StyleArray = StyleValue[];
+export type StyleValue = StyleArray | StyleDictionary | string | number | null | undefined;
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 42e449896928..724fabfe2c7c 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js
@@ -46,6 +46,19 @@ export function Attribute(node, context) {
node.metadata.needs_clsx = true;
}
+ // style={[...]} or style={{...}} or `style={x}` need cssx to resolve the style
+ if (
+ node.name === 'style' &&
+ !Array.isArray(node.value) &&
+ node.value !== true &&
+ node.value.expression.type !== 'Literal' &&
+ node.value.expression.type !== 'TemplateLiteral' &&
+ node.value.expression.type !== 'BinaryExpression'
+ ) {
+ // TODO ??? mark_subtree_dynamic(context.path);
+ node.metadata.needs_cssx = 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 98036aa9b609..1efda94e7c64 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
@@ -594,6 +594,10 @@ function build_element_attribute_update_assignment(
} else if (is_dom_property(name)) {
update = b.stmt(b.assignment('=', b.member(node_id, name), value));
} else {
+ if (attribute.metadata.needs_cssx) {
+ value = b.call('$.cssx', value);
+ }
+
const callee = name.startsWith('xlink') ? '$.set_xlink_attribute' : '$.set_attribute';
update = b.stmt(
b.call(
@@ -635,6 +639,11 @@ function build_custom_element_attribute_update_assignment(node_id, attribute, co
value = b.call('$.clsx', value);
}
+ // We assume that noone's going to redefine the semantics of the style attribute on custom elements, i.e. it's still used for CSS styles
+ if (name === 'style' && attribute.metadata.needs_cssx) {
+ value = b.call('$.cssx', value);
+ }
+
const update = b.stmt(b.call('$.set_custom_element_data', node_id, b.literal(name), value));
if (has_state) {
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 d0d800d3cbc5..68a4d88b12b6 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
@@ -108,11 +108,25 @@ export function build_element_attributes(node, context) {
} else {
attributes.push(attribute);
}
- } else {
- if (attribute.name === 'style') {
- style_index = attributes.length;
- }
+ } else if (attribute.name === 'style') {
+ style_index = attributes.length;
+ if (attribute.metadata.needs_cssx) {
+ const clsx_value = b.call(
+ '$.cssx',
+ /** @type {AST.ExpressionTag} */ (attribute.value).expression
+ );
+ attributes.push({
+ ...attribute,
+ value: {
+ .../** @type {AST.ExpressionTag} */ (attribute.value),
+ expression: clsx_value
+ }
+ });
+ } else {
+ attributes.push(attribute);
+ }
+ } else {
attributes.push(attribute);
}
}
diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js
index 003c3a2c4945..9bd1db38d911 100644
--- a/packages/svelte/src/compiler/phases/nodes.js
+++ b/packages/svelte/src/compiler/phases/nodes.js
@@ -49,7 +49,8 @@ export function create_attribute(name, start, end, value) {
value,
metadata: {
delegated: null,
- needs_clsx: false
+ needs_clsx: false,
+ needs_cssx: false
}
};
}
diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts
index a544cd1dec09..f04f05697ee7 100644
--- a/packages/svelte/src/compiler/types/template.d.ts
+++ b/packages/svelte/src/compiler/types/template.d.ts
@@ -481,6 +481,8 @@ export namespace AST {
delegated: null | DelegatedEvent;
/** May be `true` if this is a `class` attribute that needs `clsx` */
needs_clsx: boolean;
+ /** May be `true` if this is a `style` attribute that needs `cssx` */
+ needs_cssx: boolean;
};
}
diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js
index 2dba2d797a4a..baea92001c0e 100644
--- a/packages/svelte/src/internal/client/dom/elements/attributes.js
+++ b/packages/svelte/src/internal/client/dom/elements/attributes.js
@@ -13,7 +13,7 @@ import {
set_active_effect,
set_active_reaction
} from '../../runtime.js';
-import { clsx } from '../../../shared/attributes.js';
+import { clsx, cssx } from '../../../shared/attributes.js';
/**
* The value/checked attribute in the template actually corresponds to the defaultValue property, so we need
@@ -291,6 +291,10 @@ export function set_attributes(
next.class = clsx(next.class);
}
+ if (next.style) {
+ next.style = cssx(next.style);
+ }
+
if (css_hash !== undefined) {
next.class = next.class ? next.class + ' ' + css_hash : css_hash;
}
diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js
index d78f6d452e84..a209b0e67b61 100644
--- a/packages/svelte/src/internal/client/index.js
+++ b/packages/svelte/src/internal/client/index.js
@@ -155,7 +155,7 @@ export {
$window as window,
$document as document
} from './dom/operations.js';
-export { attr, clsx } from '../shared/attributes.js';
+export { attr, clsx, cssx } 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 728f2ebc2a3c..64fc6a8166d4 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, clsx } from '../shared/attributes.js';
+import { attr, clsx, cssx } from '../shared/attributes.js';
import { is_promise, noop } from '../shared/utils.js';
import { subscribe_to_store } from '../../store/utils.js';
import {
@@ -204,6 +204,10 @@ export function css_props(payload, is_html, props, component, dynamic = false) {
* @returns {string}
*/
export function spread_attributes(attrs, classes, styles, flags = 0) {
+ if (attrs.style) {
+ attrs.style = cssx(attrs.style);
+ }
+
if (styles) {
attrs.style = attrs.style
? style_object_to_string(merge_styles(/** @type {string} */ (attrs.style), styles))
@@ -552,7 +556,7 @@ export function props_id(payload) {
return uid;
}
-export { attr, clsx };
+export { attr, clsx, cssx };
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 a561501bf4f6..6c9a6f0fcce3 100644
--- a/packages/svelte/src/internal/shared/attributes.js
+++ b/packages/svelte/src/internal/shared/attributes.js
@@ -40,3 +40,46 @@ export function clsx(value) {
return value ?? '';
}
}
+
+/**
+ * Format a CSS key/value
+ * @param {[string,any]} value
+ * @returns {string|null}
+ */
+function cssx_format([k, v]) {
+ if (v == null) {
+ return null;
+ }
+ v = ('' + v).trim();
+ if (v === '') {
+ return null;
+ }
+ if (k[0] !== '-' && k[1] !== '-') {
+ k = k
+ .replaceAll('_', '-')
+ .replaceAll(/(?<=[a-z])[A-Z](?=[a-z])/g, (c) => '-' + c)
+ .toLowerCase();
+ }
+ return k + ':' + v;
+}
+
+/**
+ * Build a style attributes based on arrays/objects/strings
+ * @param {any} value
+ * @returns {string|null}
+ */
+export function cssx(value) {
+ if (value == null) {
+ return null;
+ }
+ if (typeof value === 'object') {
+ if (value instanceof CSSStyleDeclaration) {
+ // Special case for CSSStyleDeclaration
+ return value.cssText;
+ }
+ return (Array.isArray(value) ? value.map(cssx) : Object.entries(value).map(cssx_format))
+ .filter((v) => v)
+ .join(';');
+ }
+ return value;
+}
diff --git a/packages/svelte/tests/runtime-runes/samples/cssx/_config.js b/packages/svelte/tests/runtime-runes/samples/cssx/_config.js
new file mode 100644
index 000000000000..c1a8d1efae5f
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/cssx/_config.js
@@ -0,0 +1,93 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ html: `
+
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/cssx/main.svelte b/packages/svelte/tests/runtime-runes/samples/cssx/main.svelte
new file mode 100644
index 000000000000..0b0bb813a30f
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/cssx/main.svelte
@@ -0,0 +1,69 @@
+
+
+