How to build Web Components with Svelte

The Svelte logo on a gray background picture

anchorWe are hosting Svelte Week on July 21–24...four days of full-day Svelte workshops!

Learn how to build fullstack apps with Svelte: auth, testing, migrating to runes and how to introduce Svelte in your non Svelte project.
Get your ticket now!

When I started learning HTML, I was quite fascinated by how a simple <marquee> tag could magically make the text inside of it start to move.

Come on! Isn't this cool?

But especially when I started building for the web, there weren't that many cool tags: for instance, <input type="date" /> was not a thing back then, and the number of people who actually surfed the web with JavaScript explicitly disabled was a concern to have (gosh, I miss the times when the web had all these cool words like surfing the web... I'll secretly continue to call myself a Web Master until the doom of humanity).

Over the years, CSS and HTML became more and more powerful, and nowadays we can do things that weren't even imaginable 18 years ago. When I got back into web development from my detour into photography, I started exploring the space again. One of the first things I learned was about this pretty new API available in browsers: the Web Components API!

anchorThe Web Components API

For those unfamiliar (and who don't want to read the linked MDN article), let's do a quick recap of this API. On the surface, it's absolutely great: you can create your own HTML element! To do so, you just need to create a class that extends HTMLElement, define a connectedCallback (which is invoked when your component is actually mounted to the DOM), create "the shadow DOM" (which is a way-too-cool name for the invisible root element of your custom element that you can append to), and use the custom element registry to define your very own tag.

class FancyButton extends HTMLElement {
  constructor() {
    super();
  }

  connectedCallback() {
    const shadow = this.attachShadow({ mode: "open" });
    const btn = document.createElement("button");
    btn.textContent = "I'm fancy";
    btn.addEventListener("click", () => {
      alert("I'm super fancy actually! 😎");
    });
    shadow.append(btn);
  }
}

customElements.define("fancy-button", FancyButton);

If you paste this code in the script tag of your app, you can then just use <fancy-button> as if it were a native element. In this case, it might not be super useful (it's just a button with a fixed textContent and event listener), but can you imagine the possibilities? 😍

anchorThe harsh reality

Unfortunately, as reality always does, the truth is a bit less gleaming: the Web Components API starts pretty simple but gets complicated quite quickly:

  • Do you want to listen for prop changes? You need to define a static observedAttributes array of strings that contains all the attributes you want to listen for and an attributeChangedCallback method that will be invoked every time they change.
  • Do you want to accept some content inside? You need to learn the intricacies of the <slot /> element and how it interfaces with the outside world.
  • You need to learn about properties vs attributes.
  • You need to work with imperative vanilla JavaScript, which can get unwieldy pretty quickly.
  • And please, let's not talk about integrating custom elements with forms!

So while initially it might look like a walk in the park, writing good custom elements can get very complex very fast. Let's see an example of a very basic counter component with particular styling.

class CounterComponent extends HTMLElement {
  #count = 0;
  #preSentence = "";
  #btn;
  #controller;
  static observedAttributes = ["count", "pre-sentence"];

  constructor() {
    super();
  }

  attributeChangedCallback(attribute, old_value, new_value) {
    if (attribute === "count") {
      // attributes are always strings so we need to parse it
      this.#count = parseInt(new_value);
    } else if (attribute === "pre-sentence") {
      // attributes in the DOM can't be camel case so we need to listen for `pre-sentence`
      // even if our variable is camel case
      this.#preSentence = new_value;
    }
    // this callback can be called before the connectedCallback if the attribute
    // is present when it's mounted, so we need to check if btn is there before updating
    if (this.#btn) {
      this.#btn.textContent = `${this.#preSentence} ${this.#count}`;
    }
  }

  connectedCallback() {
    const shadow = this.attachShadow({ mode: "open" });
    // we use an abort controller to clean up all the event listeners
    // on disconnect
    this.#controller = new AbortController();
    // we need a reference to the button to update its text content when the attribute changes
    this.#btn = document.createElement("button");
    this.#btn.textContent = `${this.#preSentence} ${this.#count}`;
    this.#btn.addEventListener(
      "click",
      () => {
        this.#count++;
        this.#btn.textContent = `${this.#preSentence} ${this.#count}`;
      },
      {
        signal: this.#controller.signal,
      }
    );
    const style = document.createElement("style");
    // we can just reference `button` since all the styles will be "encapsulated" in the shadow DOM
    style.innerHTML = `button{
	all: unset;
	border-radius: 100vmax;
	background-color: #ff3e00;
	color: #111;
	padding: 0.5rem 1rem;
	cursor: pointer;
  font-family: monospace;
}`;
    shadow.append(style);
    shadow.append(this.#btn);
  }

  disconnectedCallback() {
    // cleanup every event listener
    this.#controller.abort();
  }
}

customElements.define("counter-component", CounterComponent);

Once you define it, you can use it like this:

<counter-component count="10" pre-sentence="count"></counter-component>

<button>change counts</button>
<button>change sentence</button>

And to interact with it externally:

const [change_count, change_sentence] = document.querySelectorAll("button");
const counter = document.querySelector("counter-component");

change_count.addEventListener("click", () => {
  counter.setAttribute("count", "42");
});

change_sentence.addEventListener("click", () => {
  counter.setAttribute("pre-sentence", "count is");
});

You can play around with it on this CodePen, but as you can see, that's a lot of code for a relatively simple component.

anchorThe better solution

As is often the case whenever there's something pretty cool but pretty complex, engineers do what they do best: ABSTRACT!

So when Svelte was released, the Svelte team thought: since Svelte is meant to create components... what if we allow a Svelte component to be compiled to a custom element?

And that's exactly what the customElement option in the Svelte config allows you to do!

Once you set that to true, every component in your application will be compiled as usual, but an extra line would be added at the end. This is an "empty" Svelte component compiled with the customElement option set to true:

import "svelte/internal/disclose-version";
import "svelte/internal/flags/legacy";
import * as $ from "svelte/internal/client";

export default function Empty($$anchor) {}

$.create_custom_element(Empty, {}, [], [], true);

I bet you can guess which line we are interested in! πŸ˜„

The create_custom_element function receives the Svelte component as input (and a series of arguments we will explore later) and wraps it with a class that handles all the annoying bits for you. However, it doesn't define a custom element for you unless you specify the tag name with <svelte:options customElement="my-tag" /> in your component. However, you can find the class on the element property of the function, which means that if you want, you can use the Svelte component just as a Svelte component, but if you want to use it as a custom element, you can manually register it as you like:

import Empty from "./Empty.svelte";

customElements.define("empty-component", Empty.element);

But an Empty component is quite boring... let's start to fill this component up by recreating our first fancy button example.

<svelte:options customElement="fancy-button" />

<button onclick={()=> alert("I'm super fancy actually! 😎")}>I'm fancy</button>

This compiles to this JavaScript code:

import "svelte/internal/disclose-version";
import "svelte/internal/flags/legacy";
import * as $ from "svelte/internal/client";

var on_click = () => alert("I'm super fancy actually! 😎");
var root = $.from_html(`<button>I'm fancy</button>`);

export default function FancyButton($$anchor) {
  var button = root();

  button.__click = [on_click];
  $.append($$anchor, button);
}

$.delegate(["click"]);
customElements.define(
  "fancy-button",
  $.create_custom_element(FancyButton, {}, [], [], true)
);

As you can see, since we declared the tag with svelte:options, it's automatically defining it, which means that if we want to use it, we just need to do:

import "./FancyButton.svelte";

But where things really shine is when you start to add props to the component... let's rebuild our counter-component with Svelte!

<!--we need CSS injected to inject the styles directly into the custom element-->
<svelte:options
	css="injected"
	customElement={{
	tag: "counter-component",
	props: {
		count: {
			type: "Number"
		},
		preSentence: {
			attribute: "pre-sentence",
		}
	}
}} />

<script>
	let { count, preSentence } = $props();
</script>

<button onclick={() => count++}>{preSentence} {count}</button>

<style>
	button{
		all: unset;
		border-radius: 100vmax;
		background-color: #ff3e00;
		color: #111;
		padding: 0.5rem 1rem;
		cursor: pointer;
		font-family: monospace;
	}
</style>

Writing code like this is already much more manageable:

  • We don't need to handle registering and canceling event listeners.
  • We don't need to update the DOM manually every time.
  • In turn, we don't need to keep a reference to the button around.
  • We don't need to parse out count manually... Svelte is doing that for us when we specify the props attribute of customElement.
  • Similarly, to sync between preSentence and pre-sentence, we just need to add the attribute property.

Svelte also helps us with how we can interact with our custom element from the outside world:

<script>
	import "./CounterComponent.svelte";

	let counter;
</script>

<counter-component bind:this={counter} count={10} pre-sentence="count"></counter-component>

<button onclick={()=>counter.count = 42}>change counts</button>
<button onclick={()=>counter.preSentence = "count is"}>change sentence</button>

As you can see, Svelte created getters and setters for our props so that we can access them without having to rely on setAttribute.

Once again, here's a Svelte playground if you want to play around with it. (Unfortunately, based on how the playground doesn't refresh the page and how you can't redefine the same custom element twice, you'll need to refresh the page if you want to make a change to the code πŸ€·πŸ»β€β™‚οΈ)

anchorSo... are Web Components the future?

Well... unfortunately no: as we've seen, Web Components are pretty powerful and they are getting even more powerful with the addition of Declarative Shadow DOM, but they are not short of pitfalls:

  • They require JavaScript, which means that if you don't build them following a certain design pattern, they might "pop" into existence as they mount, causing layout shift.
  • They can't be server-side rendered, unless carefully built to support server-side rendering. Although there's been some experimentation on this... you can learn more about this from fellow Svelte ambassador Theodor Steiner who presented a talk about Svelte and Web Components at last Svelte Summit and is also providing the Svelte community with a lot of tools to more easily build Web Components with Svelte.
  • Passing any "complex" prop to them (everything that is not a literal value) requires you to JSON.stringify them 😬
  • Bundle size could also be hard to optimize since each Web Component will need the Svelte runtime to work (this will be less problematic if you build your component library in one single package).

So while, to this day, it's still better to use a framework to build the majority of your application, if you can't wait to use Svelte in your React project or you want to build some self-contained component that you want to be able to just drop in every project of yours, then Web Components could be the right solution.

anchorConclusion

Web Components are a really powerful feature of the Platformβ„’, but they really didn't gain much traction because of how much more maintainable it is to write your components with a framework... but sometimes they can be the right solution, and I absolutely love the fact that Svelte allows you to build them in the same simple way with just a bit of configuration.

anchorWe are hosting Svelte Week on July 21–24...four days of full-day Svelte workshops!

Learn how to build fullstack apps with Svelte: auth, testing, migrating to runes and how to introduce Svelte in your non Svelte project.
Get your ticket now!

Stay up to date on Svelte

Subscribe to our newsletter and stay up to date about the latest events, workshops, and other news around Svelte & SvelteKit.

Team up with us to go further!

Our experts are ready to guide you through your next big move. Let us know how we can help.
Get in touch