Codementor Events

Turn Vue.js 3.0 SPA to embedded widget

Published Dec 10, 2023Last updated Dec 11, 2023

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:

Vue.js 3

Tailwind CSS

Shadow Root

Vite

Custom Element API

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 promt 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 promt 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).

To install Tailwind CSS in our project, we will follow these steps. We already have a project set up, so we can integrate Tailwind CSS as follows:

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 promt:

Created Tailwind CSS config file: tailwind.config.jseated PostCSS config file: postcss.config.js

So as a result of this command will be created 2 important files in root of our project:

Tailwind CSS config file: tailwind.config.js

PostCSS config file: postcss.config.js

tailwindcss.config.js we will configure in further chapters as it eventually turns to be important for customization to solve the issue with responsive design for our embedded widget.

postcss.config.js we will update in next step.

Step 3: Configure tailwind.config.js

Let’s adjust 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

Let’s open the postcss.config.js file and configure it to use Tailwind CSS and autoprefixer. Let’s update the file to look like this:

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  }
}
Step 4: Create CSS File

Now we need to create a new CSS file, for example, 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 5: Import CSS

Let’s import the CSS file we just created into our main application file - 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.

By the way, if you are new to Tailwind CSS, you can find more detailed information about this framework, including use cases, implementation, and installation, in the official Tailwind CSS documentation for additional details and customization options.

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 required functions from Vue
    import { ref, defineProps } from 'vue';

    // Define props for the component
    const props = defineProps({
        date: {
            type: String,
            required: true
        },
        title: {
            type: String,
            required: true
        },
        end: {
            type: String,
            required: true
        },
        color: {
            type: String,
            required: true
        }
    });

    // Calculate the timestamp for the countdown end date
    const countdownDate = ref(new Date(props.date).getTime());

    const countdownDateIsInvalid = isNaN(countdownDate.value);

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

    // Track whether the countdown has expired
    const countdownExpired = ref(false);

    // Determine the text color based on the provided prop or default to black
    const textColor = props.color || 'black';

    // Update the countdown values every second
    // setInterval schedules repeated execution every delayed value (1000 ms = 1 second)
    setInterval(() => {
        // Get the current timestamp
        const now = new Date().getTime();

        // Calculate the remaining time in milliseconds
        const distance = countdownDate.value - now;

        // Check if the countdown has expired
        if (distance <= 0) {
            countdownExpired.value = true;
            return;
        }

        // Calculate days, hours, minutes, and seconds
        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

Let’s add component to App.vue with necessary properties needed to our Countdown widget.

<template>
  <CountDown 
      :date="props.date"
      :title="props.title"
      :end="props.end" 
      :color="props.color" />
</template>

<script setup>
  import CountDown from "@/components/CountDown.vue";

  // Import required functions from Vue
  import { defineProps } from 'vue';

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

Now, our SPA is ready and provided countdown logic.

Step 3: Run the application to check results

Let’s run application to check results and test our countdown logic. Run in console a command to serve application in development mode:

npm run serve

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



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';

// Function to bootstrap and mount the Vue.js application
export function bootstrap(target, attributes) {

    // Create a new Vue application instance
    // second argument is object of attributes that will be 
    // converted to properites in App.vue
    const app = createApp(App, attributes)
    
     // Mount the Vue application onto the specified target element
    app.mount(target);
}
Step 2: Make custom element in main.js

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

// Import the bootstrap function from the 'bootstrap.js' file
import { bootstrap } from './bootstrap.js';

// Define a custom element 'countdown-widget' using the Custom Elements API
customElements.define('countdown-widget', class extends HTMLElement {
 
  async connectedCallback() {

    // Create a shadow DOM for encapsulatio
    const shadowRoot = this.attachShadow({ mode: 'open' });

    // Fetch <countdown-widget> attributes 
    // to pass as properties for App.vue

    const attributes = {
      'date': this.getAttribute('date'),
      'title': this.getAttribute('title'),
      'end': this.getAttribute('end'),
      'color': this.getAttribute('color')
    }
    
    // Bootstrap the Vue.js application within the shadow DOM
    bootstrap(shadowRoot, attributes);
  }
});

Step 3: Configure vite.config.js

Let’s configure vite.config.js to compile necessary files: .js file for script to include in 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

Let’s check package.json file. Check if there is a property:

"type": "module",

If yes, then let’s change it to:

"type": "commonjs",
Step 5: Compile widget files

Let’s compile widget files (.js and .css) which will be outputed in /dist folder.

npx vite dist

In folder /dist we may find 2 files:

In promt we may see:

VITE v5.0.7 ready in 209 ms

  ➜ Local: http://localhost:5173/
  ➜ Network: use --host to expose
  ➜ press h + enter to show help

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 and deploy it on an external site. Consequently, we require a stable production build for our widget, ensuring that widget.js is accessible through a dedicated host.

Step 6: Configure virtual host

Let’s configure virtual host (in this example, it will be Nginx).

Find more here how to deal with it if you are new in Nginx.

This file will be somewhere 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;
     }
}

We may also need to add the " vue-js-embedded-widget" host to our local hosts list (while we are on a local server). You can find it in /etc/hosts. Add the following entry:

127.0.0.1 vuejs-embedded-widget

We also need to make symlink from /etc/nginx/sites-available/vuejs-embedded-widget.conf to /etc/nginx/sites-enabled.

sudo ln -s /etc/nginx/sites-available/your_domain /etc/nginx/sites-enabled/

Let’s restart nginx server:

sudo service nginx restart
Step 7: Make build

Let’s make a build to compile widget.js and style.css and to have them available by our virtual host:

npx vite build

Now we are able to reach compiled widget files:

http://vuejs-embedded-widget/widget.js

http://vuejs-embedded-widget/style.css

Let's include our compiled s tyle.css directly into the App.vue template using the <link> attribute. This implies that our App.vue will be within the Shadow Root, resulting in the inclusion of style.css inside the Shadow Root.

Consequently, CSS styles from style.css will be applied to the widget's DOM content.

<template>
  <!--Link to compiled style.css-->
  <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 required functions from Vue
  import { defineProps } from 'vue';

  // Define props for the component
  const props = defineProps({
      date: {
          type: String,
          required: true
      },
      title: {
          type: String,
          required: true
      },
      end: {
          type: String,
          required: false,
          defaut: 'Countdown is end.'
      },
      color: {
          type: String,
          required: false,
          default: '#FF0000'
      }
  });
</script>
Step 9: Re-compile files with new build to apply changes in App.vue
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.

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). And, of course, the site where it will be implemented should also be on a local server in this case.

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></body>

Let’s put <countdown-widget></countdown-widget> with necessary attributes in external site body in place where it should be rendered:

<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:



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 repository with code covered in this article on Labro Dev Github.

Thanks for reading this article, and I hope it was helpful for you. Please subscribe to my blog - Labro Dev Substack, where I share my experience related to web development, with a focus on Laravel and Vue.js.

Discover and read more posts from Petro Lashyn
get started