diff --git a/.changeset/style-attribute-objects.md b/.changeset/style-attribute-objects.md
new file mode 100644
index 0000000000..63a65575a6
--- /dev/null
+++ b/.changeset/style-attribute-objects.md
@@ -0,0 +1,5 @@
+---
+'svelte': minor
+---
+
+feat: allow `style` attribute to be an object or array, mirroring the `class` attribute
diff --git a/documentation/docs/03-template-syntax/17-style.md b/documentation/docs/03-template-syntax/17-style.md
index 6ddb128f4a..940bec5f34 100644
--- a/documentation/docs/03-template-syntax/17-style.md
+++ b/documentation/docs/03-template-syntax/17-style.md
@@ -1,8 +1,99 @@
---
-title: style:
+title: style
tags: template-style
---
+There are two ways to set inline 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.56, `style` can be an object or array, and is converted to a CSS declaration string using the same rules as the [`class` attribute](class).
+
+If the value is an object, each entry becomes a declaration:
+
+```svelte
+
+
...
+```
+
+> [!NOTE]
+> Object keys are written as the literal CSS property name — `'background-color'`, not `backgroundColor`. Svelte does not convert between `camelCase` and `kebab-case`.
+
+Entries whose value is `false`, `null`, `undefined` or the empty string are skipped, which is useful for conditional styles:
+
+```svelte
+
+
...
+```
+
+If the value is an array, the truthy entries are combined:
+
+```svelte
+
+
...
+```
+
+Arrays can contain arrays, objects and strings, which Svelte flattens. This is useful for combining local styles with props, for example:
+
+```svelte
+
+
+
+
+```
+
+The user of this component has the same flexibility to use a mixture of objects, arrays and strings:
+
+```svelte
+
+
+
+
+```
+
+CSS custom properties work the same way:
+
+```svelte
+
...
+```
+
+Since Svelte 5.56, Svelte also exposes the `StyleValue` type, which is the type of value that the `style` attribute on elements accepts. This is useful if you want to use a type-safe style value in component props:
+
+```svelte
+
+
+
...
+```
+
+## The `style:` directive
+
The `style:` directive provides a shorthand for setting multiple styles on an element.
```svelte
@@ -35,12 +126,13 @@ To mark a style as important, use the `|important` modifier:
...
```
-When `style:` directives are combined with `style` attributes, the directives will take precedence,
-even over `!important` properties:
+When `style:` directives are combined with the `style` attribute, the directives take precedence,
+even over `!important` properties, and regardless of whether the attribute is a string or an object:
```svelte
This will be red
This will still be red
+
This will be red
```
You can set CSS custom properties:
diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts
index f18b7dea98..d67890f3fc 100644
--- a/packages/svelte/elements.d.ts
+++ b/packages/svelte/elements.d.ts
@@ -1547,7 +1547,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;
@@ -2076,3 +2076,7 @@ export interface SvelteHTMLElements {
}
export type ClassValue = string | import('clsx').ClassArray | import('clsx').ClassDictionary;
+
+export type StyleDictionary = Record;
+export type StyleArray = Array;
+export type StyleValue = string | number | StyleDictionary | StyleArray | false | null | undefined;
diff --git a/packages/svelte/src/internal/shared/attributes.js b/packages/svelte/src/internal/shared/attributes.js
index 487a40baf3..da54b897e1 100644
--- a/packages/svelte/src/internal/shared/attributes.js
+++ b/packages/svelte/src/internal/shared/attributes.js
@@ -108,6 +108,39 @@ function append_styles(styles, important = false) {
return css;
}
+/**
+ * Convert a style attribute value (string | object | array | falsy) into a CSS
+ * declaration string. Mirrors clsx's behaviour for `class`: arrays are flattened,
+ * falsy entries are dropped, object keys are emitted verbatim (no camelCase ↔
+ * kebab-case transform), and pre-formatted strings pass through unchanged.
+ * @param {any} value
+ * @returns {string}
+ */
+function style_value_to_string(value) {
+ if (value == null || value === false || value === '') return '';
+ if (typeof value === 'string') return value;
+ if (Array.isArray(value)) {
+ var array_result = '';
+ for (var i = 0; i < value.length; i++) {
+ // Strip trailing `;` so concatenating user-authored strings (which often end
+ // with one) doesn't produce `;;` which can confuse strict CSS parsers.
+ var part = style_value_to_string(value[i]).replace(/\s*;\s*$/, '');
+ if (part) array_result += (array_result ? '; ' : '') + part;
+ }
+ return array_result;
+ }
+ if (typeof value === 'object') {
+ var object_result = '';
+ for (var key of Object.keys(value)) {
+ var v = value[key];
+ if (v == null || v === false || v === '') continue;
+ object_result += (object_result ? '; ' : '') + key + ': ' + v;
+ }
+ return object_result;
+ }
+ return String(value);
+}
+
/**
* @param {string} name
* @returns {string}
@@ -125,6 +158,13 @@ function to_css_name(name) {
* @returns {string | null}
*/
export function to_style(value, styles) {
+ // `class` accepts strings, objects and arrays via clsx; mirror that for `style`
+ // by normalising non-string values into a CSS declaration string upfront so the
+ // existing parser (which expects a string) handles directive merging unchanged.
+ if (value != null && typeof value !== 'string') {
+ value = style_value_to_string(value);
+ }
+
if (styles) {
var new_style = '';
diff --git a/packages/svelte/tests/runtime-runes/samples/style-object/_config.js b/packages/svelte/tests/runtime-runes/samples/style-object/_config.js
new file mode 100644
index 0000000000..19d4ecf8cb
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/style-object/_config.js
@@ -0,0 +1,145 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ mode: ['client', 'hydrate', 'server'],
+
+ async test({ assert, target }) {
+ const get = (/** @type {string} */ id) =>
+ /** @type {HTMLElement} */ (target.querySelector('#' + id));
+
+ // --- inline literal cases ---
+
+ // inline object literal
+ assert.equal(get('inline-object').style.color, 'red');
+ assert.equal(get('inline-object').style.backgroundColor, 'blue');
+
+ // inline array of strings
+ assert.equal(get('inline-array-strings').style.color, 'red');
+ assert.equal(get('inline-array-strings').style.backgroundColor, 'blue');
+
+ // inline array mixing strings and objects (and stray trailing semicolons)
+ assert.equal(get('inline-array-mixed').style.color, 'red');
+ assert.equal(get('inline-array-mixed').style.padding, '4px');
+ assert.equal(get('inline-array-mixed').style.margin, '2px');
+
+ // numeric values, including 0
+ assert.equal(get('numeric').style.zIndex, '0');
+ assert.equal(get('numeric').style.opacity, '1');
+ assert.equal(get('numeric').style.lineHeight, '1.5');
+
+ // nested arrays are flattened
+ assert.equal(get('nested').style.color, 'red');
+ assert.equal(get('nested').style.padding, '4px');
+ assert.equal(get('nested').style.margin, '2px');
+
+ // all entries falsy → attribute absent (or empty)
+ assert.notOk(get('all-falsy').getAttribute('style'));
+
+ // empty object → empty style attribute (parallels clsx({}) === '')
+ const empty_attr = get('empty-object').getAttribute('style');
+ assert.ok(empty_attr === '' || empty_attr === null);
+
+ // conditional inline object: dropped when the gate is false
+ assert.equal(get('conditional').style.padding, '4px');
+ assert.equal(get('conditional').style.color, '');
+
+ // `false`/null/undefined values inside an object are skipped per-property
+ assert.equal(get('falsy-property').style.color, 'red');
+ assert.equal(get('falsy-property').style.backgroundColor, '');
+ assert.equal(get('falsy-property').style.padding, '');
+ assert.equal(get('falsy-property').style.margin, '');
+
+ // reactive cases via direct $state reads: initial render
+ assert.equal(get('reactive-object').style.color, 'red');
+ assert.equal(get('reactive-object').style.backgroundColor, '');
+ assert.equal(get('reactive-array').style.padding, '2px');
+ assert.equal(get('reactive-array').style.color, 'red');
+ assert.equal(get('reactive-array').style.borderColor, '');
+
+ // CSS custom properties are emitted verbatim
+ assert.equal(get('custom-prop').style.getPropertyValue('--my-color'), 'red');
+ assert.equal(get('custom-prop').style.getPropertyValue('--scale'), '1');
+
+ // style: directive wins on overlapping property
+ assert.equal(get('directive-precedence').style.color, 'blue');
+ assert.equal(get('directive-precedence').style.padding, '4px');
+
+ // spread
+ assert.equal(get('spread').style.color, 'red');
+ assert.equal(get('spread').style.padding, '1px');
+
+ // --- $derived cases ---
+
+ // $derived returning an object
+ assert.equal(get('derived-object').style.color, 'red');
+ assert.equal(get('derived-object').style.padding, '2px');
+
+ // $derived returning a mixed array
+ assert.equal(get('derived-array').style.margin, '2px');
+ assert.equal(get('derived-array').style.color, 'red');
+ assert.equal(get('derived-array').style.borderWidth, '2px');
+
+ // $derived returning a string
+ assert.equal(get('derived-string').style.color, 'red');
+ assert.equal(get('derived-string').style.opacity, '1');
+
+ // $derived nested inside an inline array, alongside a literal
+ assert.equal(get('derived-in-array').style.outline, '1px solid red');
+ assert.equal(get('derived-in-array').style.color, 'red');
+ assert.equal(get('derived-in-array').style.padding, '2px');
+
+ // $derived gated by a condition: when falsy, no attribute should be emitted
+ assert.notOk(get('derived-conditional').getAttribute('style'));
+
+ // $derived combined with style: directive — directive wins
+ assert.equal(get('derived-directive').style.color, 'blue');
+ assert.equal(get('derived-directive').style.padding, '2px');
+
+ // $derived inside spread
+ assert.equal(get('derived-spread').style.color, 'red');
+ assert.equal(get('derived-spread').style.padding, '2px');
+
+ // $derived object with conditional falsy values
+ assert.equal(get('derived-falsy').style.color, 'red');
+ assert.equal(get('derived-falsy').style.backgroundColor, '');
+ assert.equal(get('derived-falsy').style.borderColor, 'black');
+
+ // --- reactivity ---
+
+ const button = /** @type {HTMLButtonElement} */ (target.querySelector('button'));
+ button.click();
+ flushSync();
+
+ // inline reactive: $state reads recompute
+ assert.equal(get('reactive-object').style.color, 'green');
+ assert.equal(get('reactive-object').style.backgroundColor, 'yellow');
+ assert.equal(get('reactive-array').style.color, 'green');
+ assert.equal(get('reactive-array').style.borderColor, 'green');
+ assert.equal(get('conditional').style.color, 'green');
+ assert.equal(get('custom-prop').style.getPropertyValue('--my-color'), 'green');
+ assert.equal(get('directive-precedence').style.color, 'blue'); // still wins
+ assert.equal(get('spread').style.color, 'green');
+
+ // $derived cases recompute
+ assert.equal(get('derived-object').style.color, 'green');
+ assert.equal(get('derived-object').style.padding, '8px');
+ assert.equal(get('derived-array').style.color, 'green');
+ assert.equal(get('derived-array').style.borderWidth, '8px');
+ assert.equal(get('derived-string').style.color, 'green');
+ assert.equal(get('derived-string').style.opacity, '0.5');
+ assert.equal(get('derived-in-array').style.color, 'green');
+ assert.equal(get('derived-in-array').style.padding, '8px');
+ // derived-conditional now resolves to a real object
+ assert.equal(get('derived-conditional').style.backgroundColor, 'yellow');
+ // derived-directive: directive still wins, but padding tracks the derived
+ assert.equal(get('derived-directive').style.color, 'blue');
+ assert.equal(get('derived-directive').style.padding, '8px');
+ assert.equal(get('derived-spread').style.color, 'green');
+ assert.equal(get('derived-spread').style.padding, '8px');
+ // derived-falsy: now `background-color` shows up and `border-color` drops
+ assert.equal(get('derived-falsy').style.color, 'green');
+ assert.equal(get('derived-falsy').style.backgroundColor, 'yellow');
+ assert.equal(get('derived-falsy').style.borderColor, '');
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/style-object/main.svelte b/packages/svelte/tests/runtime-runes/samples/style-object/main.svelte
new file mode 100644
index 0000000000..79c3d98b28
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/style-object/main.svelte
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+