Using Custom Elements In Svelte

Create framework-agnostic web components with Svelte 5. Build reusable, encapsulated elements that work across React, Vue, Angular, and plain HTML.

What Are Custom Elements in Svelte?

Custom elements--also known as web components--represent one of the most powerful features of the modern web platform. They allow you to create reusable, encapsulated HTML elements that work across any framework or even with plain HTML and JavaScript. Svelte 5 makes creating custom elements remarkably straightforward, combining the simplicity of Svelte's component syntax with the portability of native web standards.

When you compile a Svelte component with the custom element option enabled, Svelte generates a JavaScript class that extends HTMLElement and handles all the complex lifecycle management, attribute observation, and shadow DOM encapsulation for you. This means you can write components using familiar Svelte syntax--reactive props, event handlers, and scoped styles--while producing standards-compliant custom elements that work in any modern browser.

The key insight is that Svelte handles all the boilerplate of the web component specification for you. Instead of writing dozens of lines of code to define observed attributes, lifecycle callbacks, and shadow DOM setup, you simply write a Svelte component and let the compiler do the rest. This approach gives you the developer experience of Svelte with the portability of native web standards, making it an excellent choice for professional web development projects.

Getting Started with Custom Elements

Creating a custom element in Svelte begins with the <svelte:options> element, which tells the compiler how to treat your component. The customElement option accepts either a simple tag name string or a configuration object with detailed options for how your custom element should behave.

The simplest form uses a tag name directly, which works well for straightforward components without complex prop configurations. When you specify a tag name, Svelte automatically registers the custom element with the browser's CustomElementRegistry when the module is imported.

Basic Custom Element Example
1<svelte:options customElement="my-button" />2 3<script>4 let { variant = 'primary' } = $props();5</script>6 7<button class={variant}>8 <slot />9</button>10 11<style>12 button {13 padding: 0.75rem 1.5rem;14 border-radius: 0.5rem;15 font-weight: 600;16 cursor: pointer;17 transition: all 0.2s;18 }19 20 button.primary {21 background: #ff3e00;22 color: white;23 }24 25 button.secondary {26 background: #f0f0f0;27 color: #333;28 }29</style>

This example demonstrates a complete custom element in just a few lines of code. The component uses Svelte 5's $props() rune to declare reactive properties, a <slot> element to project content, and scoped styles that will be encapsulated within the shadow DOM. When imported into any project, this automatically registers <my-button> as a valid HTML element.

For more complex components requiring fine-grained control over attribute mapping and property behavior, you can pass a configuration object to customElement. This object supports the tag property for the element name, props for configuring individual properties, and shadow for controlling shadow DOM behavior. For teams working with multiple CSS approaches, understanding how scoped styles interact with your broader CSS architecture becomes essential.

Configuring Props and Attributes

One of the most powerful features of Svelte's custom element compilation is its intelligent prop handling. By default, any property you declare with $props() becomes both a property on the DOM element and an HTML attribute that can be set declaratively. However, the web platform has important differences between properties and attributes that you'll need to understand to build robust custom elements.

HTML attributes are always strings, while JavaScript properties can be any type. When you set <my-element count="5">, the browser passes the string "5" to your component. For numeric or boolean properties, you'll want to configure type conversion to ensure your component receives the expected JavaScript values rather than strings.

Configuring Props with Types
1<svelte:options customElement={{2 tag: "data-counter",3 props: {{4 count: {{ type: "Number" }},5 disabled: {{ type: "Boolean" }},6 label: {{ attribute: "data-label" }}7 }}8}} />9 10<script>11 let {{ count = 0, disabled = false, label = "Count" }} = $props();12</script>13 14<div class="counter">15 <span class="label">{label}</span>16 <button disabled={disabled} onclick={() => count++}>17 {count}18 </button>19</div>

This configuration demonstrates several important patterns. The type property specifies how Svelte should convert attribute values to JavaScript properties. For the count property, setting type: "Number" ensures that <data-counter count="42"> results in the number 42, not the string "42". The Boolean type handles truthy/falsy attribute patterns common in HTML.

The attribute property allows you to map a prop to a differently-named HTML attribute. The label property maps to data-label in the DOM, so consumers write <data-counter data-label="Total"> while your component receives it as the label prop.

Property Reflection

By default, changes to a prop's value on the JavaScript side do not automatically update the corresponding HTML attribute. For some use cases--particularly when working with frameworks that inspect element attributes for configuration--you may want to enable reflection, which keeps the attribute in sync with the property value.

Enabling Property Reflection
1<svelte:options customElement={{2 tag: "reflected-element",3 props: {{4 value: {{ type: "String", reflect: true }}5 }}6}} />7 8<script>9 let {{ value = "default" }} = $props();10</script>11 12<p>Value: {value}</p>13<input value={value} oninput={(e) => value = e.target.value} />

With reflect: true, setting element.value = "new value" automatically updates the value attribute on the DOM element. This bidirectional synchronization can be valuable for debugging, CSS attribute selectors, or framework integration, but it comes with performance overhead. Only enable reflection when you genuinely need the attribute to stay in sync.

Component Lifecycle and Shadow DOM

Understanding the lifecycle of Svelte custom elements is essential for building components that behave correctly in all scenarios. Svelte uses a wrapper approach: your Svelte component is wrapped in a native custom element class that manages the DOM lifecycle and delegates to your component's logic. This means your Svelte component has no knowledge that it's operating inside a custom element--the wrapper handles all the platform integration.

When a custom element is added to the DOM, the wrapper's connectedCallback fires, but the inner Svelte component isn't created immediately. Svelte defers component creation to the next microtask, which provides several benefits. Properties assigned to the element before it's inserted into the DOM are captured and applied once the component mounts, ensuring no values are lost during the asynchronous initialization.

The shadow DOM provides encapsulation for styles and structure. By default, Svelte creates a shadow root for your component, and all styles defined in the component are injected into that shadow root. This means global styles from the surrounding page don't affect your component, and your component's styles don't leak out.

Accessing the Host Element with $host

The $host rune provides access to the custom element's host DOM node from within your Svelte component. This enables advanced use cases where you need to interact directly with the custom element's API, such as calling methods, accessing properties that aren't exposed as props, or integrating with browser APIs that require the element reference.

Using $host to Access the Custom Element
1<svelte:options customElement="interactive-widget" />2 3<script>4 import {{ onMount }} from "svelte";5 6 let {{ initialValue = 0 }} = $props();7 let count = initialValue;8 let host = $host();9 10 onMount(() => {{11 // Expose a method on the custom element12 host.reset = () => {{13 count = initialValue;14 }};15 16 // Report ready state17 host.ready = true;18 }});19 20 function increment() {{21 count++;22 host.dispatchEvent(new CustomEvent("change", {{ detail: count }}));23 }}24</script>25 26<div class="widget">27 <span>Count: {count}</span>28 <button onclick={increment}>+1</button>29</div>

This example demonstrates several $host patterns. The onMount callback adds custom methods (reset) and properties (ready) to the host element, making them available to external JavaScript code. The dispatchEvent call fires a custom event that bubbles through the DOM, allowing consumers to listen for changes.

For form integration, the $host rune enables your custom element to participate in native form validation and submission. By accessing the host element and using the attachInternals API, you can make your custom element behave like a native form control.

Using Slots for Content Projection

The <slot> element is the standard web component mechanism for content projection, and Svelte supports it fully. When consumers of your custom element include content between the opening and closing tags, that content is projected into the slot, allowing you to create flexible, composable components.

Modal Component with Named Slots
1<svelte:options customElement="modal-dialog" />2 3<script>4 let {{ title = "", open = false }} = $props();5 let host = $host();6 7 function close() {{8 open = false;9 host.dispatchEvent(new Event("close"));10 }}11</script>12 13{{#if open}}14 <div class="modal-overlay" onclick={close} role="presentation">15 <div class="modal-content" onclick={(e) => e.stopPropagation()}>16 <header>17 <h2>{title}</h2>18 <button class="close-btn" onclick={close}>×</button>19 </header>20 <div class="body">21 <slot />22 </div>23 <footer>24 <slot name="footer">25 <button onclick={close}>Close</button>26 </slot>27 </footer>28 </div>29 </div>30{{/if}}

This modal component demonstrates several slot patterns. The default <slot /> projects the main content between the opening and closing tags. The named <slot name="footer"> provides a projection point for footer content, with fallback content shown when no footer content is provided.

Named slots follow a simple convention: use <slot name="slotname"> in your component, and consumers provide content with slot="slotname" on their elements.

Best Practices for Performance

Building performant custom elements requires attention to several areas: bundle size, initialization overhead, and runtime efficiency. Svelte's compilation approach naturally produces efficient code, but there are patterns and configurations that further optimize your components for production use.

The css option controls how styles are handled during compilation. By default, Svelte extracts component styles into a separate CSS file. For custom elements, you typically want css: "injected", which embeds styles directly into the compiled JavaScript. This approach ensures your components remain truly self-contained, eliminating the need for separate CSS loading in complex web applications.

Using CSS Injection for Self-Contained Components
1<svelte:options customElement="optimized-component" css="injected" />2 3<script>4 let {{ data = [] }} = $props();5</script>6 7<ul class="item-list">8 {{#each data as item}}9 <li>{item}</li>10 {{/each}}11</ul>12 13<style>14 .item-list {{15 list-style: none;16 padding: 0;17 margin: 0;18 }}19 20 .item-list li {{21 padding: 0.5rem;22 border-bottom: 1px solid #eee;23 }}24</style>

Common Pitfalls and How to Avoid Them

Working with custom elements in Svelte requires awareness of several edge cases and platform behaviors that can cause unexpected behavior.

Event Listener Naming: Svelte treats any prop starting with on as an event listener, which aligns with HTML conventions but can be surprising. If you have a prop that logically should start with on, you'll find it doesn't work as expected. Instead, name your prop without the prefix and document that consumers pass it to the on attribute version.

Timing and Initialization: Properties set before the element is inserted into the DOM are captured and applied after the component mounts, but this only applies to property assignments. Calling methods on the custom element before it's mounted won't work because the wrapper class hasn't been fully initialized.

Cross-Framework Considerations: Custom elements work across frameworks, but each framework has its own conventions. React requires special handling for custom element events and properties. When building components for cross-framework use, use lowercase tag names with hyphens, expose data through attributes, use standard events, and document any framework-specific usage patterns.

Use Cases and When to Choose Custom Elements

Custom elements shine in scenarios requiring broad reusability and framework independence. Component libraries distributed to other teams, design systems that must work across multiple applications, and embeddable widgets for third-party sites all benefit from the framework-agnostic nature of web components.

Consider custom elements when building UI libraries that multiple teams will consume. Rather than maintaining React, Vue, and Angular versions of each component, a single Svelte custom element works everywhere. This dramatically reduces maintenance burden and ensures visual and behavioral consistency across all consuming applications. Organizations building comprehensive design systems can benefit from our web development expertise to implement custom element strategies at scale.

For internal projects within a single technology stack, custom elements may add unnecessary complexity. If all your applications use React, building native React components is simpler. Reserve custom elements for situations where the write-once, run-anywhere benefit genuinely applies.

Building a Component Library

Organizing custom elements into a cohesive library requires attention to versioning, documentation, and distribution. Each element should be independently versioned, allowing consumers to update individual components without risking breaking changes. Package your library for distribution through npm, with ES module output for modern bundlers and UMD or IIFE output for direct script include.

Key Benefits of Svelte Custom Elements

Framework Agnostic

Components work in React, Vue, Angular, or plain HTML without modification

Shadow DOM Encapsulation

Styles are isolated and won't leak or be affected by global styles

Native Performance

Svelte compiles to efficient vanilla JavaScript without virtual DOM overhead

Standard Compliant

Built on native web standards that are supported by all modern browsers

Frequently Asked Questions

Ready to Build Reusable Components?

Create a design system that works across your entire technology stack with Svelte custom elements.