Svelte Components as Web Components

This post shows how to write an app in Svelte Kit, while still being able to export and use individual components on other websites (that don’t use Svelte).

What we are trying to accomplish

I have recently started working on a rather big project for a client that wants to radically improve the UI. The project is a complex online shop, and the original UI has been written with a server side rendered template engine. It has been decided that it would make sense to use a more modern technology that allows us to do SSR (Server Side Rendering) as well as turn the whole app into a SPA (Single Page Application).

But rewriting the UI of a big project often means that no new features or changes can be made to the existing app until the rewrite is complete (which for this project could take up to a full year). For many companies that is unacceptable, that’s why they push to keep the existing technology, which causes frustrated developers and bad apps.

After discussing with my team, we decided to go with Svelte for one specific reason: because it does not rely on a runtime (like React does) it allows for very small builds that can be injected anywhere on a site. We came up with the idea of building a complete new Svelte Kit app, and export individual components (like headers, footer, etc…) as web components.

This way we have the best of both worlds: a clean fresh start with Svelte Kit, and the ability to use some of the new components in the old project, thus being able to make updates to the old app without doing twice the work.

What are web components anyway?

In short, web components are a set of tools that allow you to create your own custom HTML elements. One big part of this is the shadow DOM, which scopes the HTML and CSS, so that outside CSS does not influence the elements in your custom element, and vice versa. This is particularly powerful because it allows you to design and test a component in isolation, embed it in a website, and know that any CSS rules on that page will not break your component.

It also takes care of creating and destroying the element for you, and you can use a web component with any framework, since it is in essence just another HTML element.

What’s the difference to Svelte components?

Although the concepts are fairly similar (a Svelte component also scopes the CSS, and creates a sort of custom element for you) there are a few notable differences:

  • Svelte components are PascalCase like this: <HeaderMenu>, whereas web components are kebab-case: <header-menu>
  • Svelte components accept different types of props (strings, numbers, objects, etc…), whereas web components only accept strings. So in Svelte you can write this: <HeaderMenu items={['Home', 'News']}> but for Web Components you’ll need to either use JSON.stringify or use your own syntax like: <header-menu items="home,news"> which you have to parse accordingly in your component.
  • Although Svelte Components scope their CSS they do not live in shadow DOM, so they are influenced by global CSS definitions (e.g.: CSS resets, or font definitions on the body element). Web components (if they use shadow dom) start with a completely clean slate and do not inherit any CSS.
  • Events are handled differently.
  • There are a few other minor differences but they were not relevant in our case.

Using Svelte with Web Components

As good as the idea sounded, I quickly ran into a few problems. Svelte was not designed to mix normal Svelte components with web components. You tell the svelte compiler to either build them as usual, or set the customElements property to true, at which point it will output web components.

So I created a separate rollup.config.js in our project, that configured the svelte compiler to customElements: true , and pointed it to a .ts file that only imported the components I was interested in.

At first it seemed like this was the solution to our problem but I quickly realised that this caused quite a few problems.

First of all, a svelte component needs to have a **<svelte:options tag="header-menu">** tag at the beginning of the file, so svelte knows which name to give the custom element. But if your svelte.config.js (which builds your Svelte Kit app) does not use customElements then you’ll get a warning for these files.

But the bigger issue is that you’ll also need to reference all other components with their custom element tag name instead of the Svelte name. So if inside your HeaderMenu.svelte file you want to import the SignInelement, you can’t write: <SignIn /> but you need to use <sign-in></sign-in>otherwise the <header-menu> web component will not find the element. This is a big issue since suddenly you can’t simply use the same code, because the components in your Svelte Kit app still need to reference the components with their PascalCase name.

Lastly, it doesn’t really work with imported libraries, or libraries that generate svelte components on the fly (like often icon libraries do) since they are not meant to be used as web components.

We started thinking about these problems, and thought about writing preprocessors or rollup plugins that automatically inject the <svelte:options tag="element-name"> tags, replace the <ElementName> tags with <element-name>, etc… but this all seemed very finicky and prone to breaking.

The solution: creating our own Web Components

So we ended up with a solution that has worked great so far. Instead of compiling our svelte components as web components, we compile them as usual.

I then wrote a small Custom Element wrapper with the native Web Components API, that instantiates the Svelte component. This works perfectly, because Svelte automatically attaches the styles to the element provided, in this case the shadow root.

How it all works

Setup your Svelte Kit app as usual and start building your app. The only thing you need to be careful here, is to think about which components are going to be used outside of your Svelte Kit app, since they might not be able to access things like your stores or authentication data unless you provide it to them.

Next, create a web-components.ts file (I’m using TypeScript, but of course everything applies to JavaScript as well). This file will create the wrapper for your svelte components.

In this file, import all the components that you will want to use, and create the JavaScript web component wrapper like this:

import HeaderMenu from './lib/components/HeaderMenu.svelte'
import type { SvelteComponent } from 'svelte'

customElements.define(
  // I recommend prefixing your custom elements, but for this example
  // I'm keeping it simple.
  'header-menu',
  class extends HTMLElement {
    _element: SvelteComponent

    constructor() {
      super()

      // Create the shadow root.
      const shadowRoot = this.attachShadow({ mode: 'open' })

      // Instantiate the Svelte Component
      this._element = new HeaderMenu({
        // Tell it that it lives in the shadow root
        target: shadowRoot,
        // Pass any props
        props: {
          // This is the place where you do any conversion between
          // the native string attributes and the types you expect
          // in your svelte components
          items: this.getAttribute('items').split(',')
        }
      })
    }
    disconnectedCallback(): void {
      // Destroy the Svelte component when this web component gets
      // disconnected. If this web component is expected to be moved
      // in the DOM, then you need to use `connectedCallback()` and
      // set it up again if necessary.
      this._element?.$destroy()
    }
  }
)

As you can see, the customElements API is quite simple, and this is all that’s necessary to setup a nice wrapper. If you have more elements, I recommend splitting it up into separate files. I have also created a superclass that all my web components wrapper extend that contains some utility functions, but it’s up to you how complex you want to get.

Bundle it with rollup

Now that we have a web-components.ts file, we need to build our svelte components so we can import them on our old site. We can’t use our svelte.config.js for that, since that will build our Svelte Kit app, so let’s just create a new rollup.config.js file with the sole purpose of compiling the web-components.ts file.

import commonjs from '@rollup/plugin-commonjs'
import resolve from '@rollup/plugin-node-resolve'
import typescript from '@rollup/plugin-typescript'
import svelte from 'rollup-plugin-svelte'
import { terser } from 'rollup-plugin-terser'
import sveltePreprocess from 'svelte-preprocess'

const production = !process.env.ROLLUP_WATCH

export default {
  // The file we created with our web component wrapper.
  input: 'web-components.ts',
  output: {
    sourcemap: !production,
    format: 'iife',
    name: 'app',
    // We output it to public. This way, our svelte kit
    // app will also host the web components.
    file: 'public/web-components.js'
  },
  // Normal rollup svelte configuration. Nothing fancy
  // happening here.
  plugins: [
    typescript(),
    svelte({
      preprocess: sveltePreprocess({
        sourceMap: !production,
        postcss: true
      }),
      // We just make sure that no global CSS is injeced
      // into the page that imports this script.
      emitCss: false,
      compilerOptions: {
        dev: !production

        // customElement: true,
      }
    }),
    resolve(),
    commonjs(),
    // Minify the production build (npm run build)
    production && terser()
  ]
}

This will generate a web-component.js file that can be imported on any page, where you’ll then be able to use the custom elements with the svelte components inside like this:

<header-menu items="home,about"></header-menu>

Final notes

This approach is fantastic, because it allows us to build a clean new app while still being able to backport some of the UI elements to the old client. Because The footprint of svelte is so small, the bundle size will not impact the old site negatively and there is no risk of interfering with other elements on the site since it’s all scoped in shadow dom.

There are a few caveats to think about however: Since the web components are scoped, it also means that you need to be careful not to depend on global CSS in your Svelte Kit app. As mentioned before, typical examples of this are CSS resets, and font definitions. You can solve this in two ways: either you bundle some sort of CSS reset in your web components inside the web-component.js or you import the styles inside your svelte components directly. In any case you need to test them as web components and make sure they look right before you add them to a production deployment.

I hope this post helped some of you. Happy coding.

Need a Break?

I built Pausly to help people like us step away from the screen for just a few minutes and move in ways that refresh both body and mind. Whether you’re coding, designing, or writing, a quick break can make all the difference.

Give it a try
Pausly movement