Contact Me
Blog

Building Web Components with Svelte

Svelte is a lightning-fast alternative to tools such as React, Vue and Angular. It can be used to build rich user interfaces, or — with SvelteKit — whole sites or apps, optionally including server-side rendering (SSR).

Another area where Svelte excels is for creating vanilla web components. Wether they'e interactive (e.g. a rich text editor), dynamic (e.g. a customisable navigation bar) or merely decorative (e.g. a card), web components allow you to define your own HTML tags to add additional functionality to pretty much any site or app, whatever technology it uses. All you need to do is include the relevant JavaScript.

Creating web components requires a little bit of configuration, so in this post I'll demonstrate the process by walking through an example web component.

I'll be building a small "retro" digital clock, illustrated below:

Two examples of a Svelte component that displays a digital clock

After digging around for a while, during which time I found a number of online resources which were out-of-date, I eventually came across this to use as a starting point. It provides all the necessary dependencies, a Vite configuration for development and a build script specifically designed to generate web components that can be integrated with any sort of website/web application.

Installation and Setup

I'm using the degit package to scaffold the project; this is similar to a git clone except that it only copies down the latest branch, ignoring the Git history.

(Alternatively, the full source code is available on Github.)

Here's the command:

npx degit https://github.com/dariuszsikorski/svelte-webcomponent digital-clock

Inside the digital-clock folder, you should see the following:

├── LICENSE
├── README.md
├── index.html
├── package-lock.json
├── package.json
├── public
│   └── vite.svg
├── src
│   ├── App.svelte
│   ├── app.css
│   ├── assets
│   │   └── svelte.svg
│   ├── global.d.ts
│   ├── lib
│   │   └── Counter.svelte
│   ├── main.ts
│   ├── vite-env.d.ts
│   └── wc
│       ├── MyComponent.wc.svelte
│       └── web-components.ts
├── svelte.config.js
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

There are quite a lot of files there, though most of them we don't need to touch; and indeed, some are redundant.

You shouldn't need to touch main.js; its job is simply to mount the application component that gets rendered in the browser during development; that's defined in App.svelte.

You can launch this in a browser — with hot reloading — with the following command:

npm run dev

In order to define a web component and include it as a custom element in the resulting build, you need to do the following:

  • Create it as a file with the extension .wc.svelte in src/wc
  • Use the customElement option to define the tag used to embed it
  • Import it into src/web-components.ts
  • Export it from that same file

We'll go through all four steps with a real example shortly.

Let's take a quick look at the Vite configuration, focussing on the options that are particularly relevant to creating web components.

Take a look at the below:

// vite.config.js
export default defineConfig(({ command }) => {
  if (command === 'build') {
    return {
      plugins: [
        // Process normal Svelte files (exclude .wc.svelte files)
        svelte({
          exclude: '**/*.wc.svelte'
        }),
        // Process web component files (only include .wc.svelte files) and compile them as custom elements
        svelte({
          include: '**/*.wc.svelte',
          compilerOptions: {
            customElement: true
          }
        }),
    // ...    

What this indicates is that files with the .wc.svelte extension should be treated as custom elements; web components, in other words. It also allows for "normal" Svelte components without the .wc.svelte extension; these live in the src/lib folder and will act as dependents of the web components; that's to say, they're not designed to be exported as standalone components.

This snippet configures the build:

build: {
  lib: {
    // Use the web component registration file as the library entry point
    entry: resolve(__dirname, 'src/wc/web-components.ts'),
    name: 'MyWebComponents',
    fileName: (format) => `my-web-components.${format}.js`,
    // Build both ES and UMD formats
    formats: ['es', 'umd']
  },

This indicates that the build will reference src/wc/web-components.ts — essentially, including anything that that file exports — and create two output files; one using ES and one UMD. These will be named as follows:

  • dist/my-web-components.es.js
  • dist/my-web-components.umd.js

The package includes an example component, so let's start by removing that. Begin by clearing out the reference to src/wc/MyComponent.wc.svelte in src/web-components.ts, to leave just this:

export {}

Now you can safely delete src/lib/Counter.svelte and src/wc/MyComponent.wc.svelte, along with src/assets/svelte.svg.

The Digit Component

Before we can build the clock, we need a component for an individual digit.

Create an empty file, src/lib/Digit.svelte. Remember, files in this directory won't be exported; we'll only use this digit component within the clock. Furthermore, the fact that it doesn't have the .wc.svelte extension means that it won't be built as a custom element, as per the Vite configuration above.

The digit is based on the following SVG:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
   version="1.1"
   id="svg1"
   width="180"
   height="320"
   viewBox="0 0 180.00001 320"   
   xmlns="http://www.w3.org/2000/svg"
   xmlns:svg="http://www.w3.org/2000/svg">
  <defs
     id="defs1" />  
  <g
     id="g1">     
    <path
       id="path34"
       d="m 42.950207,281.78094 -29.212,29.21466 c 2.924,1.72667 6.324,2.73334 9.96666,2.73334 H 155.92354 c 3.57866,0 6.92533,-0.97067 9.816,-2.64134 L 136.43287,281.78094 H 42.950207"
       style="fill:#e8e7e6;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.133333" />
    <path
       id="path35"
       d="m 40.499537,184.00894 -29.33067,-21.08133 c -4.35199,3.60533 -7.12399,9.04933 -7.12399,15.144 V 294.0676 c 0,5.788 2.51333,10.972 6.49066,14.57067 l 29.964,-29.964 v -94.66533"
       style="fill:#e8e7e6;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.133333" />
    <path
       id="path36"
       d="m 138.60487,42.850281 v 94.369329 l 27.59467,18.268 c 5.624,-3.46 9.384,-9.652 9.384,-16.73867 V 22.750281 c 0,-4.72134 -1.66534,-9.05067 -4.43867,-12.44 l -32.54,32.54"
       style="fill:#e8e7e6;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.133333" />
    <path
       id="path37"
       d="m 138.60487,278.39294 30.36,30.36 c 4.05333,-3.59734 6.61867,-8.836 6.61867,-14.68534 V 178.07161 c 0,-6.39467 -3.064,-12.05867 -7.79067,-15.648 l -29.188,21.79866 v 94.17067"
       style="fill:#e8e7e6;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.133333" />
    <path
       id="path38"
       d="M 136.35021,39.544941 168.36487,7.530281 C 164.9742,4.756941 160.6462,3.0902813 155.92354,3.0902813 H 23.704867 c -4.72,0 -9.05066,1.6666597 -12.44,4.4399997 l 32.01467,32.01466 h 93.070673"
       style="fill:#e8e7e6;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.133333" />
    <path
       id="path39"
       d="M 40.499537,42.324941 8.484867,10.310281 c -2.77333,3.38933 -4.43999,7.71866 -4.43999,12.44 V 138.74894 c 0,7.248 3.93199,13.56533 9.77066,16.97333 l 26.684,-18.408 V 42.324941"
       style="fill:#e8e7e6;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.133333" />
    <path
       id="path40"
       d="m 14.687537,160.61961 26.22133,-18.44534 h 97.696003 l 25.552,17.59067 -25.552,19.388 H 40.499537 l -25.812,-18.53333"
       style="fill:#e8e7e6;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.133333" />
  </g>
</svg>

Each path represents a segment, and I've named them like this:

An SVG image showing the segments that make up a digital number

tl refers to top left, t to top, c to the centre segment and so on.

For clarity, I re-ordered the paths to go in a clockwise direction and added classes to match the labels above. I also removed the inline styles — those are going to get moved to CSS. Here's one of them:

<path       
  d="M 40.499537,42.324941 8.484867,10.310281 c -2.77333,3.38933 -4.43999,7.71866 -4.43999,12.44 V 138.74894 c 0,7.248 3.93199,13.56533 9.77066,16.97333 l 26.684,-18.408 V 42.324941"
  class="tl" />

By adding a <style> tag to a Svelte component, you can define CSS styles that are isolated to the component in question. Let's add those now, to the bottom of the file:

<style>    
.digit {
    width: 24px;
    height: 40px;
    display: flex;
}    
path {
    fill-opacity:1;
    fill-rule:nonzero;
    stroke:none;
    stroke-width:0.133333;
    fill: var(--digital-clock-off-color, #e8e7e6);
    transition: fill 250ms;
}
.on {
    fill:var(--digital-clock-on-color, #262a34);
}
svg {
    width: 20px;
    height: auto;
}
</style>

.digit refers to a wrapper we're going to define shortly, and most of the CSS properties for the path element are the ones that were specified inline for each path individually.

I've added an on class; this indicates a segment is illuminated; thus, the colour changes. The colours are defined as CSS variables, with defaults.

Including a transition on the path's fill property will implement an animation whenever the number changes.

Now we need to determine which segments are illuminated, and in order to do that we first need to determine which number to display. We get that from the component properties:

<script>
  let { number } = $props();  
</script>

I'm creating a variable for each segment, named as per the image above. These are derived properties, and are defined as follows:

<script>
  let { number } = $props();
  let tl = $derived([0,4,5,6,8,9].includes(number)),
    t = $derived([0,2,3,5,6,7,8,9].includes(number)),
    tr = $derived([0,1,2,3,4,7,8,9].includes(number)),
    c = $derived([2,3,4,5,6,8,9].includes(number)),
    bl = $derived([0,2,6,8].includes(number)),
    b = $derived([0,2,3,5,6,8,9].includes(number)),
    br = $derived([0,1,3,4,5,6,7,8,9].includes(number));
</script>

The last thing we need to do is conditionally add the on class to the segments, according to whether the digit being displayed requires it to be illuminated:

<div class="digit">
  <svg>
    <path       
      d="M 40.499537,42.324941 8.484867,10.310281 c -2.77333,3.38933 -4.43999,7.71866 -4.43999,12.44 V 138.74894 c 0,7.248 3.93199,13.56533 9.77066,16.97333 l 26.684,-18.408 V 42.324941"
      class="tl {tl ? 'on' : ''}" />

The Web Component

Now it's time to build the web component for the clock itself.

Create a new file, src/wc/DigitalClock.wc.svelte, and add the following script tag:

<script>
  import { SvelteDate } from "svelte/reactivity";
  import Digit from '../lib/Digit.svelte';
  let time = new SvelteDate();
  let hours = $derived(time.getHours());
  let minutes = $derived(time.getMinutes());
  let seconds = $derived(time.getSeconds());

  $effect(() => {
    const interval = setInterval(() => {      
      time.setTime(Date.now());
    }, 1000);

    return () => {
      clearInterval(interval);
    };
  });
</script>

This creates a date object using Svelte's reactive SvelteDate class, then uses an $effect to create a ticker that ensures that it always represents the current time. Additionally it defines derived variables that represent the hours, minutes and seconds.

We need a separator, too; a colon, essentially. This will be included twice; rather than duplicate it, we can define a snippet:

{#snippet separator()}
<div class="separator">
  <svg
    width="40"
    height="300"
    viewBox="0 0 10.583333 79.375"
    version="1.1"
    id="svg1">  
    <g>
        <rect        
        id="rect1"
        width="9"
        height="9"
        x="0.91877002"
        y="20.704327" />
        <rect        
        id="rect1-5"
        width="9"
        height="9"
        x="0.91877002"
        y="47.84948" />
    </g>
  </svg>
</div>
{/snippet}

We need to extract the digits from the hour/minute/second. The first one is the value divided by ten and then floored:

Math.floor(hours/10)

For the second, we can use the modulus:

hours % 10

Putting that together, we can define the HTML for the clock using the Digit component we defined previously:

<div class="clock">
  <Digit number={Math.floor(hours / 10)} />
  <Digit number={hours % 10} />
  {@render separator()}  
  <Digit number={Math.floor(minutes / 10)} />
  <Digit number={minutes % 10} />
  {@render separator()}
  <Digit number={Math.floor(seconds / 10)} />
  <Digit number={seconds % 10} />
</div>

Some styling:

<style>
  .clock {
    display: flex;    
  }  
  .separator {
    width: 12px;
    display: flex;
  }
  .separator svg {
    width: 8px;
    height: 40px;
  }
  .separator svg rect {
    fill: var(--digital-clock-on-color, #262a34);
  }
</style>

The final thing we need to do is specify the tag name for the custom web component. Add the following to the top of the file:

<svelte:options customElement="digital-clock" />

Back to src/wc/web-components.ts now, and we need to create a reference to the new component. Replace the contents of the file with this:

import Clock from './DigitalClock.wc.svelte';

export { Clock }

This is going to ensure that when we run the build process, our new component gets included.

Next, open up src/App.svelte so we can see the new component in action in a dev environment:

<script lang="ts">
  import './wc/web-components'
</script>

<main>
  <div class="clock-container">
    <digital-clock/>
  </div>

  <div class="clock-container dark">
    <digital-clock 
      style:--digital-clock-off-color="rgba(255,255,255,0.2"
      style:--digital-clock-on-color="yellow"></digital-clock>
  </div>
</main>

<style>
  .clock-container {
    padding: 20px;
  }
  .clock-container.dark {
    background: #111;
  }
</style>

This creates two versions, somewhat akin to light and dark mode. Note how in the "dark" version, we can inject the on/off colours by setting the relevant CSS variables using the style: tag.

All being well, providing you've run npm run dev you should see the two clocks.

Now it's time to run the build process:

npm run build

All being well, this will generate two files:

  • dist/my-web-components.es.js
  • dist/my-web-components.umd.js

Trying it Out

Create a new folder with the following index.html file:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Digital Clock</title>
    <style>
      body {
        background: #222;
      }
      :root {
        --digital-clock-off-color: rgba(255,255,255,0.2);
        --digital-clock-on-color: yellow;
     }
    </style>
    <script type="module" src="./my-web-components.es.js"/>
  </head>
  <body>
    <main>
      <digital-clock/>
    </main>
  </body>
</html>

It's important to set the type on the script tag to module; otherwise, it won't work.

Copy dist/my-web-components.es.js into the folder, and then launch it in a browser via your web server of choice.

Here's a simple way to do that:

npm install http-server -g
http-server .

Open up a browser to localhost:[PORT], and you should see the clock in action.

The full source code is available on Github.