feat: allow style attribute to be an object or array

Mirrors the clsx-style ergonomics that `class` has had since 5.16. The
`style` attribute now accepts an object, array, or any nested combination,
normalised by a small runtime helper alongside the existing `to_style`.
Object keys are emitted verbatim (no camelCase conversion), falsy entries
are dropped, and existing `style:` directive precedence is unchanged.
pull/18176/head
Mathias Picker 5 days ago
parent dc5bd887b5
commit e4ad69ea76

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: allow `style` attribute to be an object or array, mirroring the `class` attribute

@ -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
<div style="color: red; padding: 4px;">...</div>
<div style={`color: ${color};`}>...</div>
```
### 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
<!-- results in `style="color: red; background-color: blue;"` -->
<div style={{ color: 'red', 'background-color': 'blue' }}>...</div>
```
> [!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
<!-- results in `style="color: red;"` if `active` is falsy,
`style="color: red; background-color: yellow;"` otherwise -->
<div style={{ color: 'red', 'background-color': active && 'yellow' }}>...</div>
```
If the value is an array, the truthy entries are combined:
```svelte
<!-- if `faded` and `large` are both truthy, results in
`style="opacity: 0.5; padding: 16px;"` -->
<div style={[faded && 'opacity: 0.5', large && 'padding: 16px']}>...</div>
```
Arrays can contain arrays, objects and strings, which Svelte flattens. This is useful for combining local styles with props, for example:
```svelte
<!--- file: Button.svelte --->
<script>
let { style, children, ...rest } = $props();
</script>
<button {...rest} style={['padding: 4px 8px', style]}>
{@render children?.()}
</button>
```
The user of this component has the same flexibility to use a mixture of objects, arrays and strings:
```svelte
<!--- file: App.svelte --->
<script>
import Button from './Button.svelte';
let highlighted = $state(false);
</script>
<Button
onclick={() => highlighted = true}
style={{ 'background-color': highlighted && 'yellow' }}
>
Highlight me
</Button>
```
CSS custom properties work the same way:
```svelte
<div style={{ '--columns': columns }}>...</div>
```
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
<script lang="ts">
import type { StyleValue } from 'svelte/elements';
const props: { style: StyleValue } = $props();
</script>
<div style={['padding: 2px', props.style]}>...</div>
```
## 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:
<div style:color|important="red">...</div>
```
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
<div style:color="red" style="color: blue">This will be red</div>
<div style:color="red" style="color: blue !important">This will still be red</div>
<div style:color="red" style={{ color: 'blue' }}>This will be red</div>
```
You can set CSS custom properties:

@ -1547,7 +1547,7 @@ export interface SVGAttributes<T extends EventTarget> 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<string, string | number | false | null | undefined>;
export type StyleArray = Array<StyleValue>;
export type StyleValue = string | number | StyleDictionary | StyleArray | false | null | undefined;

@ -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 = '';

@ -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, '');
}
});

@ -0,0 +1,82 @@
<script>
let active = $state(false);
let color = $state('red');
let size = $state(2);
let derived_object = $derived({ color, padding: size + 'px' });
let derived_array = $derived(['margin: 2px', { color, 'border-width': size + 'px' }]);
let derived_string = $derived(`color: ${color}; opacity: ${active ? 0.5 : 1};`);
let derived_conditional = $derived(active && { 'background-color': 'yellow' });
let derived_spread = $derived({ style: { color, padding: size + 'px' } });
let derived_with_falsy = $derived({
color,
'background-color': active ? 'yellow' : false,
'border-color': active ? null : 'black'
});
</script>
<!-- inline object literal -->
<div id="inline-object" style={{ color: 'red', 'background-color': 'blue' }}></div>
<!-- inline array of strings -->
<div id="inline-array-strings" style={['color: red', 'background-color: blue']}></div>
<!-- inline array mixing strings and objects, with trailing semicolons in the strings -->
<div id="inline-array-mixed" style={['color: red;', { padding: '4px', margin: '2px' }]}></div>
<!-- numeric values are kept as-is (including zero) -->
<div id="numeric" style={{ 'z-index': 0, opacity: 1, 'line-height': 1.5 }}></div>
<!-- nested arrays are flattened -->
<div id="nested" style={[['color: red'], [{ padding: '4px' }, ['margin: 2px']]]}></div>
<!-- every entry is falsy → no style attribute -->
<div id="all-falsy" style={[false, null, undefined, '']}></div>
<!-- empty object → empty style attribute (matches clsx({}) = '') -->
<div id="empty-object" style={{}}></div>
<!-- conditional inline object: when the condition is false the whole object is dropped -->
<div id="conditional" style={[{ padding: '4px' }, active && { color: 'green' }]}></div>
<!-- object value that is `false` is filtered out at the property level -->
<div id="falsy-property" style={{ color: 'red', 'background-color': false, padding: null, margin: undefined }}></div>
<!-- inline reactive: rebuilt each render via direct $state reads -->
<div id="reactive-object" style={{ color, 'background-color': active && 'yellow' }}></div>
<div id="reactive-array" style={['padding: 2px', { color, 'border-color': active && 'green' }]}></div>
<!-- CSS custom properties are emitted verbatim -->
<div id="custom-prop" style={{ '--my-color': color, '--scale': 1 }}></div>
<!-- style: directive wins on overlapping property -->
<div id="directive-precedence" style={{ color: 'red', padding: '4px' }} style:color="blue"></div>
<!-- spread carrying an object -->
<div id="spread" {...{ style: { color, padding: '1px' } }}></div>
<!-- $derived returning an object -->
<div id="derived-object" style={derived_object}></div>
<!-- $derived returning a mixed array -->
<div id="derived-array" style={derived_array}></div>
<!-- $derived returning a string (existing behaviour, exercised through the same path) -->
<div id="derived-string" style={derived_string}></div>
<!-- $derived nested inside an inline array, alongside a literal -->
<div id="derived-in-array" style={['outline: 1px solid red', derived_object]}></div>
<!-- $derived gated by a condition: when falsy, no attribute should be emitted -->
<div id="derived-conditional" style={derived_conditional}></div>
<!-- $derived combined with style: directive — directive must win -->
<div id="derived-directive" style={derived_object} style:color="blue"></div>
<!-- $derived inside spread -->
<div id="derived-spread" {...derived_spread}></div>
<!-- $derived object with conditional falsy values -->
<div id="derived-falsy" style={derived_with_falsy}></div>
<button onclick={() => { active = !active; color = active ? 'green' : 'red'; size = active ? 8 : 2; }}>toggle</button>
Loading…
Cancel
Save