Create an Elementor widget using VueJS + Tailwind

Everyone wants a faster performing website today. Reducing server requests by keeping your interactions on the client-side is one of the best way to gain a perception of speed and improve UX.

Visual website builders have certainly come a long way since I first starting building websites. I consider Elementor to be one of the best available and by the number of plugin installs it currently has, many others do as well.

One of Elementor’s most powerful features is the ability to create your own custom widgets. If it doesn’t come in the Free or Pro plugin, you can find it in one of many Elementor widget kits available for sale, or you can build it yourself.

Today we’re going to opt for the latter.

Our task today is to build a simple, reactive widget using VueJS for interactivity and style it with Tailwind. Personally, I like to keep the NPM packages to a minimum, so I’ll be demonstrating my own light-weight recipe for building the assets for this widget.

Our widget is a settings form. After compiling and transpiling it looks like below. I know, it’s not sexy, but it’s for demonstration purposes only, so deal with it.

Sample Elementor widget using VueJS + Tailwind

If you’ve already integrated Elementor widgets into your theme, this step will be a bit redundant for you. I typically like to drop a widgets folder within the includes directory of my theme.

Create a structure like so:

themes/
├─ theme-name/
│  ├─ includes/
│  │  ├─ widgets/
│  │  │  ├─ widget-name/
│  │  │  │  ├─ dist/
│  │  │  │  ├─ src/
│  │  │  │  │  ├─ css/
│  │  │  │  │  ├─ js/
│  │  │  │  │  ├─ views/
│  │  │  │  │  │  ├─ components/
│  │  │  │  ├─ init.php
│  │  │  │  ├─ package.json
│  │  │  │  ├─ webpack.mix.js

Within your init.php add the following code:

<?php

namespace Crafter\Widgets;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly
}

/***
 * Class Sample_Form
 * @package Crafter\Widgets
 */
class Sample_Form extends \Elementor\Widget_Base {
	
	private $_widget_id = 'sample-form';
	
	public function __construct( $data = [], $args = null ) {
		
		parent::__construct( $data, $args );
		
		// Styles for this widget are transpiled at theme level using Tailwind
		// Reference root package.json > scripts > widget-*

		wp_register_script(
            $this->_widget_id . '-script',
			get_template_directory_uri() . '/includes/widgets/'.$this->_widget_id . '/dist/app.js',
			['elementor-frontend-modules'], // These libraries must be loaded first
			null, true );
	}
	
	public function get_name() {
		return $this->_widget_id;
	}
	
	public function get_title() {
		return 'Sample Form';
	}
	
	public function get_icon() {
		return 'eicon-elementor-circle';
	}
	
	public function get_categories() {
		return [ 'basic' ];
	}

	public function get_script_depends() {
		return [$this->_widget_id.'-script'];
	}
	
	protected function _register_controls() {

	}
	
	protected function render() {
		
		$element_id = $this->_widget_id . '-' . $this->get_id();
		
		echo '<div id="' . $element_id . '" class="sample-form loading">Loading ...</div>';
	}

}

Setup your package.json similar to this:

{
  "name": "sample-form",
  "version": "1.0.0",
  "description": "",
  "main": "src/js/app.js",
  "scripts": {
    "dev": "mix",
    "watch": "mix watch",
    "watch-poll": "mix watch -- --watch-options-poll=1000",
    "hot": "mix watch --hot",
    "prod": "mix --production"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "laravel-mix": "^6.0.39",
    "vue": "^3.2.26",
    "vue-loader": "^16.8.3"
  }
}

Open your widget directory in the terminal and execute npm install to get those assets ready.

After creating the webpack.mix.js file, paste in these contents:

let mix = require('laravel-mix');

mix.js('src/js/app.js', 'dist/')
	.vue({ version: 3});

Next let’s create the UI for the widget in src/views/App.vue.

<template>
  <div class="shadow-md rounded-lg p-10 divide-y w-[500px]">
    <div v-for="setting in settings" class="flex items-center justify-between py-2">
      <span class="flex-grow flex flex-col">
        <span class="text-sm font-medium text-gray-900" id="availability-label">{{setting.name}}</span>
        <span class="text-sm text-gray-500" id="availability-description">{{setting.description}}</span>
      </span>
      <button @click="change(setting)" class="shadow rounded-full px-3 py-1 uppercase font-bold text-white border-0" :class="setting.value ? 'bg-green-600' : 'bg-neutral-400'">{{setting.value ? 'On' : 'Off'}}</button>
    </div>
  </div>
</template>

<script>

import { reactive } from 'vue';

export default {
  name: 'App',
  components: {},
  setup() {
    const settings = reactive( [
      {name: 'Setting A', description: 'Nulla amet tempus sit accumsan. Aliquet turpis sed sit lacinia.', value: false},
      {name: 'Setting B', description: 'Aenean egestas accumsan ipsum feugiat mattis.', value: true},
      {name: 'Setting C', description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', value: false},
    ] );

    const change = ( setting ) => {
      setting.value = !setting.value;
    }

    return {settings, change};
  },
  mounted() {
    // Access widget settings using: `this.settings.key_name`;
  },
};
</script>

<style></style>

We now need some code to startup the App. Create a startup.js file in src/js/ like so:

import {createApp} from "vue";
import App from "../views/App";

export default class Startup {

	run( widgetSelector ) {

		// Find all elements that match the widget selector
		let elements = document.querySelectorAll( widgetSelector );

		elements.forEach( ( el ) => {

			// Create Vue app instance
			const app = createApp( App );

			// Retrieve the widget config from data- attribute of parent
			const config = el.parentElement.parentElement.getAttribute( 'data-settings' );

			// Parse the config and set in app properties
			app.config.globalProperties.settings = JSON.parse( config );

			// Render the app
			app.mount( '#' + el.id );

		});

		return this;
	}
}

This will locate any html elements that match the given selector and render your widget within. It also injects any Elementor control settings you’ve configured into each instance of the App.

The last piece of this widget is the main javascript file in src/js/app.js, which handles the Elementor frontend initialization event. It runs your widget startup only after the Elementor client-side assets have fully loaded.

import Startup from './startup';

// Wait for the Elementor Frontend to load
jQuery( window ).on( 'elementor/frontend/init', () => {

	const widgetIdentifier = 'sample-form';

	// Wait for the widget to be ready
	elementorFrontend.hooks.addAction( 'frontend/element_ready/global', ( $scope ) => {

		// Check for the correct type of widget
		const widgetType = $scope.data( 'widget_type' );

		if ( widgetType && $scope.data( 'widget_type' ).indexOf( widgetIdentifier ) > -1 ){

			// The widget is ready, now startup
			new Startup().run( '.' + widgetIdentifier );
		}

	} );
});

Now we should have all the required layout and code files in place, so return to your terminal for the widget directory and run npm run watch.

You are not done.

If you quit now, you’ll be missing the widget styling. Read on to setup Tailwind.

If we setup Tailwind inside each of our widgets we’ll end up with replicated Tailwind classes overwriting each other for as many widgets as we develop. Not good.

I’ve opted to create a Tailwind workflow that runs from the root of the theme instead and transpiles all of the widget css together in one consolidated file. Here’s how you set that up.

If you already have a package.json in your theme you will need to merge a few npm packages in, otherwise paste what I have here:

{
  "name": "crafter-default",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "widget-dev": "mix",
    "widget-watch": "mix watch",
    "widget-watch-poll": "mix watch -- --watch-options-poll=1000",
    "widget-hot": "mix watch --hot",
    "widget-prod": "mix --production"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "laravel-mix": "^6.0.39",
    "sass": "^1.46.0",
    "sass-loader": "^12.4.0",
    "tailwindcss": "^3.0.11"
  },
  "dependencies": {}
}

Also in your theme root create both tailwind.config.js and webpack.mix.js files and paste the following code blocks into them:

module.exports = {
  mode: 'jit',
  content: [
    './includes/widgets/**/src/views/**/*.vue'
  ],
  theme: {},
  plugins: [],
}
let mix = require('laravel-mix');

const tailwindcss = require('tailwindcss');

mix.sass('src/scss/widgets.scss', 'assets/css/')
	.options({
		processCssUrls: false,
		postCss: [tailwindcss('./tailwind.config.js')],
	});

Open a terminal to your theme root and run npm install.

You’ll notice the mix.sass() processor is configured to output css into an assets/css folder. make sure that is created or adjust it for your theme.

Now you’re ready to build your stylesheet, so execute npm run widget-watch.

Almost done!

In order for WordPress and Elementor to know about your widget and render it properly, you need to register it using the code below. Place it in your functions.php file or in a relevant class.

add_action( 'wp_enqueue_scripts', function() {

    wp_enqueue_style(
        'crafter-widget-style',
        get_template_directory_uri() . '/assets/css/widgets.css',
        [],
        '1.0'
    );
} );

add_action( 'elementor/widgets/widgets_registered', function(){
    require_once( __DIR__ . '/includes/widgets/sample-form/init.php' );

    \Elementor\Plugin::instance()->widgets_manager->register_widget_type( new \Crafter\Widgets\Sample_Form() );
});

Okay, that’s it. Hopefully I didn’t miss a step and leave you stuck. Drop the new widget onto a page and try it out.