Creating Universal Widgets - Transforming React components into cross-framework widget

JavaScript Framework/Tools

Widget is like a mini-application or component that can be easily plugged into any website. It's a small, self-contained functionality.

We'll build a React-based widget and bundle it with rollup to create a standalone module in a way that can be used inside a Vue project.

Advantages of building a widget

  1. Rather than reinventing the wheel for each framework, a widget simplifies integration in different environments. This is useful when you want to provide a framework-agnostic solution to clients.
  2. You have a legacy component which may be challenging to integrate into the newer tech stack you want to migrate. Converting it into a widget can be a good option.
  3. You have several projects that need the same component, and the team can choose any framework and still use it.

Features of our widget

We will build an application that asks for users' feedback. It should pop up on the right bottom corner of a webpage.

This is a simple example of a widget that we will build

widget-requirement

The widget should have the following features-

  • Text should be customizable.
  • Should be able to pass a color code that will change the color theme of the widget.
  • Button text can be changed.
  • Should be able to get the rating value through a function.

In any external application, we should be able to initialize the widget with a init function with preferred settings like this-

feedbackWidget.init({
  title: "Rate your experiecnce with the website",
  theme: "#de334",
  buttonText: "Rate Us",
  handleSubmit: (val) => doSomethingWithValue(val)
})

Creating the component in React

First we will code the FeedbackWidget component in React.

/* src/components/FeedbackWidget.js */

import { useState, useEffect } from 'react';
import Rating from './Rating';

const FeedbackWidget = (props) => {
  // set default prop value
  const {
    title = 'Help Us Improve with Your Valuable Feedback!',
    buttonText = 'Submit',
    theme = '#3457dc',
    handleSubmit = () => {},
  } = props;
  
  const [rating, setRating] = useState(null);
  
  // emit method on submit button
  const submitRating = () => {
    handleSubmit(rating)
  }
  
  return (
    <div className="brand__feedbackWidget">
      <div className="feedbackWidget__container">
        {/* header section */}
        <div className="feedbackWidget__header">
          <h4 className="feedbackWidget__title">
            {title}
          </h4>
          <i class="icon-close" />
        </div>
        {/* rating section */}
        <Rating getRating={(val) => setRating(val)}/>
        {/* submit button */}
        <div className="feedbackWidget__btn--container">
          <button
            className="feedbackWidget__btn--submit"
            onClick={submitRating}
            disabled={!rating}
          >
            {buttonText}
          </button>
        </div>
      </div>
    </div>
  );
}

To keep it simple, I have decided not to use styled-components in this project. To control the theme color, we will set a variable in CSS's root using useEffect.

useEffect(() => {
    document.documentElement.style.setProperty('--base', theme);
  }, [theme]);

This --base then can be passed in css file like this

.feedbackWidget__title {
  color: var(--base);
}

We will import this component in App.js for testing.

import FeedbackWidget from "./components/FeedbackWidget";

function App() {
  return (
    <>
      <FeedbackWidget />
    </>
  );
}
export default App;

Preparing the base file of the widget

We will keep index.js as it is and make a new file on the src directory widget.js which is the file we will build.

// src/widget.js

import React from "react";
import ReactDOM from "react-dom/client";
import FeedbackWidget from "./components/FeedbackWidget";

We will declare a function object feedbackWidget with init method.

// src/widget.js

const feedbackWidget = {
  init: (config) => {
    const { title, buttonText, theme, handleSubmit } = config;
  }
}

The config is an object where we have passed the props and method as keys that we want to keep customizable.

Instead of using the default root id, we will define our own HTML element with unique id so that it does not conflict with another DOM element's id when used in external application.

// src/widget.js

// Use an existing element or create a new one
const rootElement = document.getElementById("brand__feedbackWidget--root")
  || document.createElement("div");
rootElement.id = "brand__feedbackWidget--root";

// If the element is newly created, append it to the body
if (!document.getElementById("brand__feedbackWidget--root")) {
  document.body.appendChild(rootElement);
}
const root = ReactDOM.createRoot(rootElement);

root.render(
  <React.StrictMode>
    <FeedbackWidget
      title={title}
      button={buttonText}
      theme={theme}
      handleSubmit={(val) => handleSubmit(val)}
    />
  </React.StrictMode>
);

This is the complete code for widget.js

import React from "react";
import ReactDOM from "react-dom/client";
import FeedbackWidget from "./components/FeedbackWidget";

const feedbackWidget = {
  init: (config) => {
    const { title, buttonText, theme, handleSubmit } = config;
    
    const rootElement = document.getElementById("brand__feedbackWidget--root") ||
    document.createElement("div");
    rootElement.id = "brand__feedbackWidget--root";

    if (!document.getElementById("brand__feedbackWidget--root")) {
      document.body.appendChild(rootElement);
    }

    const root = ReactDOM.createRoot(rootElement);

    root.render(
      <React.StrictMode>
        <FeedbackWidget
          title={title}
          buttonText={buttonText}
          theme={theme}
          handleSubmit={(val) => handleSubmit(val)}
        />
      </React.StrictMode>
    );
  },
};
export { feedbackWidget };

If you want to see the related components and styling, check the github repo

Bundling

We will build the widget.js file with rollup. Rollup keeps things simple and includes only what's necessary. It is easier to configure and provides a lighter bundle. We need to install the following dependencies to configure the build file.

npm i -D @babel/core @babel/preset-react rollup rollup-plugin-babel rollup-plugin-peer-deps-external rollup-plugin-postcss rollup-plugin-terser @rollup/plugin-commonjs @rollup/plugin-node-resolve rollup-plugin-peer-deps-external
  • @babel/core - Compiles modern JS code to make it compatible to run in all browsers.
  • @babel/preset-react- Compiles React specific code like JSX into standard JS for browsers to understand
  • rollup - Module bundler for packaging code
  • rollup-plugin-babel - Allows using babel within the rollup build process
  • @rollup/plugin-commonjs - Allows Rollup to understand and bundle modules written in CommonJS format
  • rollup-plugin-postcss - For style configuration
  • rollup-plugin-terser - Minifies JS file
  • @rollup/plugin-node-resolve - For finding external modules
  • rollup-plugin-peer-deps-external - For externalizing peerDependencies in a rollup bundle

Add a file rollup.config.js in the root for rollup configuration-

// rollup.config.js

// Importing Rollup plugins
import babel from 'rollup-plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import external from 'rollup-plugin-peer-deps-external';
import postcss from 'rollup-plugin-postcss';
import { terser } from 'rollup-plugin-terser';
import resolve from '@rollup/plugin-node-resolve';
import cssnano from 'cssnano';

// Rollup configuration
export default {
  // Entry point for the bundling process
  input: './src/widget.js',

  // Output configuration
  output: [
    {
      file: './brand-widget/index.js',
      format: 'cjs' // CommonJS format
    },
  ],

  // List of plugins to use during the build process
  plugins: [
    // Ensure that external dependencies are not bundled
    external(),

    // Process CSS files
    postcss({
      extract: 'widget.css', // Specific CSS file name
      plugins: [cssnano()], // Minify CSS using cssnano
    }),

    // Transpile JavaScript code using Babel
    babel({
      exclude: 'node_modules/**' // Exclude node_modules from Babel processing
    }),

    // Resolve Node.js modules during the build
    resolve(),

    // Convert CommonJS modules to ES modules
    commonjs(),

    // Minify the generated JavaScript code
    terser(),
  ]
};


Also add a config file for babel at the root folder .babelrc

{
  "presets": [
    "@babel/env",
    ["@babel/preset-react", {"runtime": "automatic"}]
  ]
}


We will add a custom command for building the widget.js file with rollup

// package.json
...
"build:widget": "rollup -c",
...

Then run

npm run build:widget

After the build is complete, brand-widget folder will be generated with minified index.js and widget.css files. We have to package the file to use as npm modules.Create a package.json file in the brand-widget directory.

{
  "name": "brand-widget",
  "version": "1.0.0",
  "author": "Riya",
  "description": "Embedded widget for collecting user's feedback",
  "main": "index.js",
  "module": "index.js",
  "exports": {
    ".": "./index.js"
  }
}


Install this package locally by running npm link at the root of brand-widget folder. To install in a local project, run

npm link brand-widget

To use this in a vue component, we can import like this-

<script>
import { feedbackWidget } from 'brand-widget/widget';
....
  mounted(){
    feedbackWidget.init({
    title: 'Rate your experience',
    theme: '#effe',
    buttonText: 'Okay',
    handleSubmit: (val) => this.someMethod(val)
  })
}
...
</script>

<style lang="css">
  @import 'feedback-widget/style/widget.css';
</style>
demo

Check the Github repository to see the full source code, and if you wish, feel free to run on your local machine and play around with it. Thanks for reading!