pull/18176/merge
Mathias Picker 20 hours ago committed by GitHub
commit 949411f615
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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:

@ -776,7 +776,7 @@ export interface HTMLAttributes<T extends EventTarget> 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;
@ -1550,7 +1550,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;
@ -2079,3 +2079,6 @@ export interface SvelteHTMLElements {
}
export type ClassValue = string | import('clsx').ClassArray | import('clsx').ClassDictionary;
type StylePrimitive = string | number | false | null | undefined;
export type StyleValue = StylePrimitive | StyleValue[] | { [key: string]: StylePrimitive };

@ -109,6 +109,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) 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}
@ -126,6 +159,10 @@ function to_css_name(name) {
* @returns {string | null}
*/
export function to_style(value, styles) {
if (value != null && typeof value !== 'string') {
value = style_value_to_string(value);
}
if (styles) {
var new_style = '';
@ -223,5 +260,7 @@ export function to_style(value, styles) {
return new_style === '' ? null : new_style;
}
return value == null ? null : String(value);
// Empty results drop the attribute entirely so that client and SSR agree
// (the directive path above already returns `null` for empty output).
return value == null || value === '' ? null : value;
}

@ -12,8 +12,8 @@ export default test({
`
<button>fork</button>
<button>commit</button>
<p style="">foo</p>
<p style="">foo</p>
<p>foo</p>
<p>foo</p>
<p>foo</p>
`
);
@ -51,8 +51,8 @@ export default test({
`
<button>fork</button>
<button>commit</button>
<p style="">foo</p>
<p style="">foo</p>
<p>foo</p>
<p>foo</p>
<p>foo</p>
`
);

@ -0,0 +1,144 @@
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
assert.equal(get('all-falsy').getAttribute('style'), null);
// empty object → attribute absent
assert.equal(get('empty-object').getAttribute('style'), 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 → no style attribute -->
<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>

@ -40,11 +40,11 @@ export default test({
assert.htmlEqual(
target.innerHTML,
`
<div style=""></div>
<div style=""></div>
<div></div>
<div></div>
<custom-element style=""></custom-element>
<custom-element style=""></custom-element>
<custom-element></custom-element>
<custom-element></custom-element>
`
);

Loading…
Cancel
Save