← Back to Journal

Vue.js

Turn Vue.js 3.0 SPA to embedded widget

Petro Lashyn Dec 10, 2023 15 min

Seamlessly integrating Vue.js 3 SPA as Embedded Widget: A Step-by-Step Guide.

Introduction

I've encountered situations where a Single Page Application needs to be utilized as an embedded widget for integration into external websites.

Let's assume that, as a default requirement, this widget should be versatile enough to be seamlessly integrated into any website, without imposing any specific conditions. This task presents several challenges. On one hand, the embedded widget must remain isolated and self-contained to achieve the desired visual aesthetics and workflow. On the other hand, it must ensure that it does not interfere with the styling of the parent site or disrupt any of the external site's functions.

In this article, I aim to provide a comprehensive, step-by-step guide on transforming a Single Page Application constructed with Vue.js 3 into a fully functional embedded widget.

In this particular context, an embedded widget refers to a compiled .js script that is incorporated into an external web page. It is specifically designated with a <custom tag> that serves as the entry point in the Document Object Model (DOM) for the widget elements, and this is where the SPA content will be rendered.

Let's jump into this concept with a practical example. Our objective is to create a countdown widget for tracking the time remaining until a specific date or event, for example start of the New Year or the end of discount campaigns or Black Friday time remaining counter. To implement this on an external website, it might appear as follows:

<html>
<head>
<script src="https://customdomain.com/countdown-widget.js"></script>
</head>
<body>
<countdown-widget date="2024-01-01 00:00:00" title="Time until the New Year:"></countdown-widget>
</body>
</html>

Let's assume that we could use some attributes to put from the external part into the widget so we could use them in our internal logic behind the widget blackbox.

Important thing is that the content of our embedded widget will be covered by the Shadow Root. Consequently, all styles will be encapsulated inside the widget and isolated from the external site. This is a very secure way to keep the widget stable on one hand and ensure that it will not affect the external site's styles.

The Shadow Root is a crucial aspect of web development, providing a means to encapsulate and isolate the styles, structure, and functionality of a web component from the rest of the document. It acts as a container for a component's content, creating a shadow DOM subtree that shields the component's internals from external styles and scripts. By utilizing Shadow Root, developers can prevent unintended interference with or from the host document, fostering modularity and maintaining a clear separation between different components on a webpage. This encapsulation is particularly valuable for creating custom elements and widgets, offering a secure and well-defined environment for their implementation and interaction within a broader web context.

Our technology stack for this project includes:


Step-by-step guide from scratch


Vue.js 3 installation by Vite

Let's create initial Vue.js 3 application by Vite.

Vite is a build tool for modern web development that focuses on providing a fast development server with instant server start and optimized build times.

To get started with Vue.js 3, we'll need to follow a few steps. Before we begin, make sure we have Node.js and npm (Node Package Manager) installed on our system. It can be downloaded from the official website: Node.js.

Once we have Node.js and npm installed, we can proceed with creating a Vue.js 3 application.

Step 1: Install Vite

Let's open terminal or command prompt and run the following command to install the Vue CLI globally:

npm install vite
Step 2: Create Vite

Let's create a Vite instance using the following command:

npm install -g create-vite

In prompt let's choose project folder, Vue as framework and Javascript as language variant.

✔ Project name: … vuejs-embedded-widget
✔ Select a framework: › Vue
✔ Select a variant: › JavaScript
Step 3: Navigate to the Project Directory

Now let's change our working directory to the newly created project:

cd vuejs-embedded-widget
Step 4: Run npm install

Let's make initial of project packages from package.json:

npm install
Step 5: Run application to test the installation
npm run dev

We may see in prompt something like that:

VITE v5.0.7  ready in 331 ms
➜  Local:   http://localhost:5173/
➜  Network: use --host to expose
➜  press h + enter to show help

After running the development server, we will be able to check our initial installation of Vue.js 3 at http://localhost:5173 (or any other available port provided by the command).


Configuring Tailwind CSS in Vue.js 3

To install Tailwind CSS in our project, we will follow these steps.

Step 1: Install Tailwind CSS and its Dependencies
npm install -D tailwindcss postcss autoprefixer
Step 2: Create Tailwind Configuration Files
npx tailwindcss init -p

We may see in prompt:

Created Tailwind CSS config file: tailwind.config.js
Created PostCSS config file: postcss.config.js
Step 3: Configure tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./index.html",
    "./src/**/*.{vue,js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
Step 4: Configure postcss.config.js
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  }
}
Step 5: Create CSS File

Create src/assets/styles/main.css and import Tailwind CSS styles:

/* src/assets/styles/main.css */
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
Step 6: Import CSS

Import the CSS file into src/main.js:

// src/main.js
import Vue from 'vue';
import App from './App.vue';
import './assets/styles/main.css';

Vue.config.productionTip = false;

new Vue({
  render: h => h(App),
}).$mount('#app');

That's it! We've successfully installed Tailwind CSS in our project.


Develop our Vue.js 3 Single Page Application

Let's now develop our functionality (Countdown till a given date/time) before jumping into the main topic of this article: transforming a simple Vue.js 3 app into an embedded widget script.

Step 1: Create a component for Countdown

Let's create a component src/components/Countdown.vue and put there a logic.

<template>
    <div class="text-center mt-2" :style="{ color: textColor }">

        <div v-if="countdownDateIsInvalid === true">
            :date property is invalid. Should be dateString in correct format.
        </div>

        <!-- Display a message when the countdown has expired -->
        <div v-if="countdownExpired && end !== null" class="text-2xl">
            <p>{{ end }}</p>
        </div>
        <!-- Display the countdown when it's still active -->
        <div v-else>
            <p class="text-4xl font-bold">
                {{ title }}
            </p>
            <!-- Display the countdown in days, hours, minutes, and seconds -->
            <p class="text-3xl font-bold mt-2">
                <span>{{ countdown.days }} days </span>
                <span>{{ countdown.hours }} hours </span>
                <span>{{ countdown.minutes }} minutes </span>
                <span>{{ countdown.seconds }} seconds </span>
            </p>
        </div>
    </div>
</template>

<script setup>
    import { ref, defineProps } from 'vue';

    const props = defineProps({
        date: { type: String, required: true },
        title: { type: String, required: true },
        end: { type: String, required: true },
        color: { type: String, required: true }
    });

    const countdownDate = ref(new Date(props.date).getTime());
    const countdownDateIsInvalid = isNaN(countdownDate.value);

    const countdown = ref({
        days: 0, hours: 0, minutes: 0, seconds: 0
    });

    const countdownExpired = ref(false);
    const textColor = props.color || 'black';

    setInterval(() => {
        const now = new Date().getTime();
        const distance = countdownDate.value - now;

        if (distance <= 0) {
            countdownExpired.value = true;
            return;
        }

        countdown.value.days = Math.floor(distance / (1000 * 60 * 60 * 24));
        countdown.value.hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
        countdown.value.minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
        countdown.value.seconds = Math.floor((distance % (1000 * 60)) / 1000);
    }, 1000);
</script>
Step 2: Add component to App.vue
<template>
  <CountDown
      :date="props.date"
      :title="props.title"
      :end="props.end"
      :color="props.color" />
</template>

<script setup>
  import CountDown from "@/components/CountDown.vue";
  import { defineProps } from 'vue';

  const props = defineProps({
      date: { type: String, required: true },
      title: { type: String, required: true },
      end: { type: String, required: false, default: 'Countdown is end.' },
      color: { type: String, required: false, default: '#FF0000' }
  });
</script>

Now, our SPA is ready and provides countdown logic.

Step 3: Run the application to check results
npm run serve

If you give the development host in browser, it will look like:

Countdown widget running in development mode


Turn SPA to Embedded Widget

Here we are in the main part of this topic. Now let's convert an SPA to an embedded widget so that we have a compiled .js script ready to be implemented on external sites.

The significant magic point here is to use Custom Elements API.

The Custom Elements API is a part of the web platform and is related to JavaScript, HTML, and web development in general. It is a standard web API that is not specific to any particular JavaScript framework or library, including Vue.js.

The Custom Elements API is a browser feature that allows developers to define and use their own custom HTML elements with encapsulated behavior. It enables you to create reusable components and extend the set of available HTML elements, making it easier to build modular and maintainable web applications.

Let's create a bootstrap.js file in our /src folder. In this file, there will be logic to mount our app into the expected DOM element (which is the target argument). This element will be from a widget tag on the external site (<countdown-widget> in our case).

Step 1: Create bootstrap.js
import { createApp } from 'vue';
import App from './App.vue';

export function bootstrap(target, attributes) {
    const app = createApp(App, attributes)
    app.mount(target);
}
Step 2: Make custom element in main.js

Let's use our bootstrap function in main.js and use Custom Element API to turn to embedded widget.

import { bootstrap } from './bootstrap.js';

customElements.define('countdown-widget', class extends HTMLElement {

  async connectedCallback() {
    const shadowRoot = this.attachShadow({ mode: 'open' });

    const attributes = {
      'date': this.getAttribute('date'),
      'title': this.getAttribute('title'),
      'end': this.getAttribute('end'),
      'color': this.getAttribute('color')
    }

    bootstrap(shadowRoot, attributes);
  }
});
Step 3: Configure vite.config.js

Let's configure vite.config.js to compile necessary files: .js file for the script to include in the external site and .css file to apply styles inside our application to be used in Shadow Root.

import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  build: {
    rollupOptions: {
      input: {
        widget: fileURLToPath(new URL('./src/main.js', import.meta.url)),
        style: './src/assets/styles/main.css'
      },
      output: {
        inlineDynamicImports: false,
        entryFileNames: '[name].js',
        chunkFileNames: '[name].js',
        assetFileNames: '[name].[ext]'
      },
    }
  }
})
Step 4: Adjust package.json

Check if there is a property "type": "module". If yes, change it to "type": "commonjs".

Step 5: Compile widget files
npx vite dist

In folder /dist we may find 2 files: widget.js and style.css.

If we go to http://localhost:5173/widget.js, we can see the compiled, minified JavaScript code. But let's transition from development mode to a configured host and production build so that we can thoroughly test the entire solution.

Step 6: Configure virtual host

Configure an Nginx virtual host (file in /etc/nginx/sites-available/):

server {
     listen 80;
     root /var/www/vuejs-embedded-widget/dist;
     server_name vuejs-embedded-widget;

     location / {
             try_files $uri $uri/ /index.html?$query_string;
     }
}

Add the host to /etc/hosts:

127.0.0.1       vuejs-embedded-widget

Create the symlink and restart nginx:

sudo ln -s /etc/nginx/sites-available/vuejs-embedded-widget /etc/nginx/sites-enabled/
sudo service nginx restart
Step 7: Make build
npx vite build

Now we are able to reach compiled widget files at http://vuejs-embedded-widget/widget.js and http://vuejs-embedded-widget/style.css.

Step 8: Link compiled style.css to Shadow Root

Let's include our compiled style.css directly into the App.vue template using the <link> attribute. Since App.vue will be within the Shadow Root, style.css will be included inside the Shadow Root, and CSS styles will be applied to the widget's DOM content.

<template>
  <link rel="stylesheet" href="http://vuejs-embedded-widget/style.css">
  <CountDown
      :date="props.date"
      :title="props.title"
      :end="props.end"
      :color="props.color" />
</template>

<script setup>
  import CountDown from "@/components/CountDown.vue";
  import { defineProps } from 'vue';

  const props = defineProps({
      date: { type: String, required: true },
      title: { type: String, required: true },
      end: { type: String, required: false, default: 'Countdown is end.' },
      color: { type: String, required: false, default: '#FF0000' }
  });
</script>
Step 9: Re-compile files
npx vite build

That's all! Now that we have widget.js, we can implement our Countdown widget to render on any website without dependencies on the website's tech stack.


Implement widget in external site

Let's assume that we are still on a local environment, so we will use http://vuejs-embedded-widget (as configured in the previous step).

Step 1: Add widget.js to site <head>
<head>
<script src="http://vuejs-embedded-widget/widget.js" async></script>
</head>
Step 2: Put widget tag inside <body>
<countdown-widget
   date="2024-06-14"
   title="Euro 2024 will start in"
   end="Euro 2024 is started!"
   color="#FF0000">
</countdown-widget>

And now it works! We may find inside DOM of the external site where the widget is embedded how it's rendered with #shadow-root block:

Shadow Root rendered in DOM


Conclusion

This tutorial describes the concept of using Vue.js 3 SPA as an embedded widget. It provides a simple example of Countdown functionality, but it can also be applied to more complex Vue.js applications involving APIs, Axios, Store management, etc.

An important aspect is the utilization of the Shadow Root, which serves as an effective means to deliver embedded components for use in external sites without the need for an <iframe>.

I will continue this topic in the next article and use this project as a base to cover some other issues related to building embedded widgets with Vue.js. For example, I'll show how to make it mobile-friendly, as we need to define the size on the parent block where the widget is embedded, not based on the size of the screen. Additionally, I will explain how to adjust the Tailwind theme configuration to correctly reflect the size definition.

You may find the repository with code covered in this article on Labro Dev Github.

Thanks for reading.

Back to Journal

Stay Updated

Join the mailing list for technical discourse, architectural logs, and research notes. No spam, ever.