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 useJSON.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 SignIn
element,
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