Svelte and Custom Elements: Data Passing Pitfalls
Svelte supports custom elements out of the box — no wrappers or configuration needed — and scores perfectly on Custom Elements Everywhere. But that doesn’t mean everything is frictionless. At Alaska Airlines, integrating the custom elements from our design system into a Svelte app surfaced a few quirks around how Svelte decides to pass data to those elements.
This article focuses on consuming custom elements (built with Lit in our case) inside a Svelte application, not on compiling Svelte components to custom elements. That said, the concepts apply regardless of how the custom elements were built.
Property vs. attribute: Svelte’s heuristic
The core thing to understand is how Svelte decides whether to pass data to a custom element as a property or as an attribute. At runtime, if a corresponding property exists on the element, Svelte sets the property. If not, it falls back to setting an attribute.
Consider a coffee-mug custom element with a size property:
<coffee-mug class="mug" size="large"></coffee-mug>
Looking at the markup, it’s natural to think both class and size are being set as attributes. But inspecting the rendered DOM in DevTools reveals only class appears as an attribute — size is set as a property. The element still renders "large" because the property was indeed set. There’s no class property on the element, so Svelte falls back to the attribute for that one. This isn’t a bug, but it’s a disconnect between the HTML you write and what Svelte actually produces.
This heuristic is what makes passing objects and arrays work seamlessly. But any magic eventually demands you look under the hood when things behave unexpectedly.
Styling hooks and reflected attributes
Problems arise when a custom element uses an attribute as a styling hook. Say we have a custom-text element that prepends "Flagged: " plus an emoji when the flag attribute is present:
import { html, css, LitElement } from 'lit';
export class CustomText extends LitElement {
static get styles() {
return css`
:host([flag]) p::before {
content: '🚩';
}
`;
}
static get properties() {
return {
flag: {
type: Boolean
}
};
}
constructor() {
super();
this.flag = false;
}
render() {
return html`<p>
${this.flag ? html`<strong>Flagged:</strong>` : ''}
<slot></slot>
</p>`;
}
}
customElements.define('custom-text', CustomText);
The :host([flag]) selector targets the element itself, but only when the flag attribute is present. When used in Svelte like this:
<script>
import './custom-elements/custom-text';
</script>
<!-- This shows the "Flagged:" text, but not 🚩 -->
<custom-text flag>Just some custom text.</custom-text>
…the "Flagged:" text shows but the emoji does not. The reason: Svelte sees an existing flag property and sets that instead of the attribute. The CSS selector never matches.
The cleanest fix is on the custom element side. Best practice is to keep primitive properties and attributes in sync, especially when attributes are used for styling. In Lit, reflecting the property is straightforward:
static get properties() {
return {
flag: {
type: Boolean,
reflect: true
}
};
}
Now the property change writes back to the attribute and the emoji appears as expected.
Forcing attributes from Svelte with actions
When you can’t modify the custom element, a Svelte action can force the attribute to be set. Actions run when a node is added to the DOM:
<script>
import './custom-elements/custom-text';
function setAttributes(node) {
node.setAttribute('flag', '');
}
</script>
<custom-text use:setAttributes>
Just some custom text.
</custom-text>
You can make the action more generic by accepting parameters:
<script>
import './custom-elements/custom-text';
function setAttributes(node, attributes) {
Object.entries(attributes).forEach(([k, v]) => {
if (v !== undefined) {
node.setAttribute(k, v);
} else {
node.removeAttribute(k);
}
});
}
</script>
<custom-text use:setAttributes={{ flag: true }}>
Just some custom text.
</custom-text>
To react to state changes, return an object with an update method from the action:
<script>
import './custom-elements/custom-text';
function setAttributes(node, attributes) {
const applyAttributes = () => {
Object.entries(attributes).forEach(([k, v]) => {
if (v !== undefined) {
node.setAttribute(k, v);
} else {
node.removeAttribute(k);
}
});
};
applyAttributes();
return {
update(updatedAttributes) {
attributes = updatedAttributes;
applyAttributes();
}
};
}
let flagged = true;
</script>
<label><input type="checkbox" bind:checked={flagged} /> Flagged</label>
<custom-text use:setAttributes={{ flag: flagged ? '' : undefined }}>
Just some custom text.
</custom-text>
This approach keeps the attribute-setting logic inside your Svelte app, no changes to the custom element required.
Lazy loading and undefined elements
Custom elements aren’t always defined when a Svelte component first renders. This happens when you defer the import until after web component polyfills load, or in server-side rendering contexts like Sapper or SvelteKit.
When the element isn’t defined yet, Svelte can’t detect any properties, so it sets everything as attributes. That’s a problem for complex data like arrays. Take this fancy-greeting element that displays a list of names:
import { html, css, LitElement } from 'lit';
export class FancyGreeting extends LitElement {
static get styles() {
return css`
p {
border: 5px dashed mediumaquamarine;
padding: 4px;
}
`;
}
static get properties() {
return {
names: { type: Array },
greeting: { type: String }
};
}
constructor() {
super();
this.names = [];
}
render() {
return html`<p>
${this.greeting},
${this.names && this.names.length > 0 ? this.names.join(', ') : 'no one'}!
</p>`;
}
}
customElements.define('fancy-greeting', FancyGreeting);
With a static import, everything works:
<script>
import './custom-elements/fancy-greeting';
</script>
<!-- This displays "Howdy, Amy, Bill, Clara!" -->
<fancy-greeting greeting="Howdy" names={['Amy', 'Bill', 'Clara']} />
But if the import is deferred until onMount:
<script>
import { onMount } from 'svelte';
onMount(async () => {
await import('./custom-elements/fancy-greeting');
});
</script>
<!-- This displays "Howdy, no one!"-->
<fancy-greeting greeting="Howdy" names={['Amy', 'Bill', 'Clara']} />
…the list doesn’t render; fallback content appears instead. Inspecting the element shows the issue:
<fancy-greeting greeting="Howdy" names="Amy,Bill,Clara"></fancy-greeting>
Svelte set the names attribute to the string representation of the array, not a stringified JSON array. Lit’s default array converter tries to parse it with JSON.parse, which throws.
Interestingly, once the data changes and Svelte re-renders, the element is now defined and Svelte switches to setting properties. The fix validates itself after an update:
<script>
import { onMount } from 'svelte';
onMount(async () => {
await import('./custom-elements/fancy-greeting');
});
let names = ['Amy', 'Bill', 'Clara'];
function addName() {
names = [...names, 'Rory'];
}
</script>
<!-- Once the button is clicked, the element displays "Howdy, Amy, Bill, Clara, Rory!" -->
<fancy-greeting greeting="Howdy" {names} />
<button on:click={addName}>Add name</button>
To make it work on first render, use an action that forces properties instead of attributes:
<fancy-greeting
greeting="Howdy"
use:setProperties={{ names: ['Amy', 'Bill', 'Clara'] }}
/>
The action iterates over the properties object and sets each one on the element, with an update function to reapply them when parameters change:
function setProperties(node, properties) {
const applyProperties = () => {
Object.entries(properties).forEach(([k, v]) => {
node[k] = v;
});
};
applyProperties();
return {
update(updatedProperties) {
properties = updatedProperties;
applyProperties();
}
};
}
With that in place, the names render correctly on the initial pass because Svelte sets the property immediately and the element picks it up once defined.
Boolean attributes before and after Svelte 3.38
Boolean attributes on custom elements have their own set of wrinkles. The behavior changed in Svelte 3.38.0, but not everyone is on the latest version, so both cases matter.
Consider a secret-box element with a boolean open property:
import { html, LitElement } from 'lit';
export class SecretBox extends LitElement {
static get properties() {
return {
open: {
type: Boolean
}
};
}
render() {
return html`<div>The box is ${this.open ? 'open 🔓' : 'closed 🔒'}</div>`;
}
}
customElements.define('secret-box', SecretBox);
Per the HTML spec, the presence of a boolean attribute means true, its absence means false. So you might expect all of these to behave identically:
<secret-box open></secret-box>
<secret-box open=""></secret-box>
<secret-box open="open"></secret-box>
In Svelte, only the last one shows the element as open. Inspecting DevTools shows no attributes at all — Svelte set the open property in all three cases:
// <secret-box open> logs ''
// <secret-box open=""> logs ''
// <secret-box open="open"> logs 'open'
render() {
console.log(this.open);
return html`<div>The box is ${this.open ? 'open 🔓' : 'closed 🔒'}</div>`;
}
For the first two, the property value is an empty string, which is falsy, so the closed state renders. Only the explicit open={true} sets a truthy value.
The workaround is to be explicit about the property value:
<secret-box open={true}></secret-box>
That aliases to open being set as a property with the boolean true. Since Svelte set properties all along, using the dedicated boolean syntax makes the intent clear.
For those on post-3.38 Svelte, this issue was addressed: if the underlying property is known to be boolean, shorthand forms like <secret-box open> behave like open={true}. This matters for copying examples straight out of component library docs.
But there’s a requirement on the custom element author: the property must have a declared default value so Svelte can infer its type. For secret-box, that means:
constructor() {
super();
this.open = true;
}
With that in place, the shorthand works correctly:
<secret-box open></secret-box>
<secret-box open=""></secret-box>
Debugging custom elements in Svelte
Most weirdness with custom elements in Svelte traces back to the property-vs-attribute heuristic. Whether data is set as a property or an attribute is a matter of timing — the element’s definition must exist — and of matching property names, but it’s not something to always solve proactively. If an issue shows up during development, inspect the rendered element to see which path Svelte took, then adjust from there. The action-based workarounds above are available for the edge cases, but they shouldn’t be the default for every integration.



