Use Vue in a static site with Web Component custom elements

Learn how to use Vue to create custom elements that are easy to drop into your static site's Markdown files.

Vue logo inside HTML tags
Vue logo inside HTML tags

Intro

Recently I rebuilt Table to Markdown, converting it from a Nuxt project to a static site built with Blurry, a pre-release static site generator ("SSG") I've been working on. I wanted the simplicity and performance of a static site, but I still needed interactivity.

"What simplicity and performance?" you ask? Well, one challenge I had with a the hybrid version is that it was hard to accurately count page views since sometimes they would be tracked in server-side code and sometimes they'd be tracked in client-side code—and I didn't want to count them twice. I had the same challenges with ad impressions, and accidentally counting those more than once could have gotten me in trouble with any ad networks I was using.

With a static site, those page views and ad impressions didn't require any thought because they would fire when the HTML was loaded, and that was that.

Using Vue for the entire site led to quite a bit of JavaScript for things that I really didn't need JavaScript for, like site navigation. Using a static site meant I could rely on regular hyperlink navigation without requiring a single line of JS, which meant faster page loads.

But, there was a catch: I still needed the interactivity I got from Vue, so I couldn't replace all of the Vue code with a static site.

Web Components with Vue

To make it easy to integrate Vue with my static site, rather than using Vue to manage the whole page, I opted to use Vue in a single custom HTML element via Web Components.

For a sneak peak of what we're building toward, here's a snippet of a Markdown file from Table to Markdown:

# Convert spreadsheet cells to Markdown

<div class="custom-element-container">
  <table-converter></table-converter>
</div>

Table to Markdown makes it easy to convert cells from Microsoft Excel, [...]

Web Components allow you to create custom self-contained HTML elements that can leverage JavaScript to do whatever you want them to. A big benefit is that once you include the JS where the Web Components are defined, you can use them just like you'd use a regular HTML element, like a <p> or <a>.

Sounds good, eh? Almost too good? Well, there are some wrinkles with this approach, but they're nothing we can't iron out.

Wrinkles

Layout shift once web component loads

If you follow this blog, you'll have noticed that I have a thing for PageSpeed scores.

One thing to keep in mind when using Web Components is that their height isn't taken into account when the page renders.

By matching the height of your Web Components and the height of a web component wrapper, you can avoid content layout shifts, which are disruptive to your visitors:

/* Main stylesheet */
.custom-element-container {
  min-height: 300px;
}

CSS scoping

To ensure that your styling is consistent in your static site and your web components, you can use the same CSS file in both places. Simply import your CSS file in your Vue component:

<style>
@import('main.css')
</style>

Child component CSS

If your single-file Vue component uses other, non-custom element Vue components with <style> tags, you may be surprised to see those styles disappear when you use your custom element.

For some workarounds:

Implementation

Now that we've identified some wrinkles and ways to smooth them out, it's time to see how to convert a Vue file into a custom element.

Vite config

First, update the Vue plugin for Vite to generate web components, and configure Vite to place your built components where you want them:

import { fileURLToPath, URL } from 'node:url'
import { resolve } from 'path'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  // Get Vue to output custom elements
  plugins: [vue({ customElement: true })],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  build: {
    // Specify a directory in your SSG's build directory
    outDir: 'dist/elements',
    rollupOptions: {
      // Improvement: loop through files in the elements directory to build this object
      input: {
        'your-component': resolve(__dirname, 'src/elements/your-component.ts'),
      },
      // Use predictable file names
      output: {
        entryFileNames(chunkInfo) {
          return `${chunkInfo.name}.js`
        },
      },
    }
  }
})

Custom element file

In a separate file, define your Vue component as a custom element, which will make your component available to use in the HTML DOM:

// src/elements/your-component.ts
import { defineCustomElement } from 'vue'

import YourComponent from '@/components/YourComponent.vue'

customElements.define('your-component', defineCustomElement(YourComponent))

Creating a separate file for each web component can help keep your JavaScript bundles small by including only the required code for that component.

Include the JS and custom element in your SSG files

Now all that's left is do is to include your custom element .js file in the relevant template file of your SSG:

<script src="/elements/your-component.js" type="module"></script>

<your-component></your-component>

Wrap-up

That's a wrap 🎁.

This article is wrapped up like a Vue single-file component in a Web Component custom element after following the above steps.