Creating an Alpine Plugin

Creating an Alpine Plugin is very simple and will allow you to create re-usable components. Alpine has some great documentation on extending functionality into your own plugin; however, it will be beneficial to give you a specific example.

Lets learn how to extract the tooltip element so that it can be used as a custom Alpine directive, like so:

<div x-data x-tooltip="Your tooltip text here">
    hover me
</div>

Below is the code for the Alpine Tooltip Element:

<div 
    x-data="{
        tooltipVisible: false,
        tooltipText: 'Tooltip text',
        tooltipArrow: true,
        tooltipPosition: 'top',
    }"
    x-init="$refs.content.addEventListener('mouseenter', () => { tooltipVisible = true; }); $refs.content.addEventListener('mouseleave', () => { tooltipVisible = false; });"
    class="relative">
    
    <div x-ref="tooltip" x-show="tooltipVisible" :class="{ 'top-0 left-1/2 -translate-x-1/2 -mt-0.5 -translate-y-full' : tooltipPosition == 'top', 'top-1/2 -translate-y-1/2 -ml-0.5 left-0 -translate-x-full' : tooltipPosition == 'left', 'bottom-0 left-1/2 -translate-x-1/2 -mb-0.5 translate-y-full' : tooltipPosition == 'bottom', 'top-1/2 -translate-y-1/2 -mr-0.5 right-0 translate-x-full' : tooltipPosition == 'right' }" class="absolute w-auto text-sm" x-cloak>
        <div x-show="tooltipVisible" x-transition class="relative px-2 py-1 text-white bg-black rounded bg-opacity-90">
            <p x-text="tooltipText" class="flex-shrink-0 block text-xs whitespace-nowrap"></p>
            <div x-ref="tooltipArrow" x-show="tooltipArrow" :class="{ 'bottom-0 -translate-x-1/2 left-1/2 w-2.5 translate-y-full' : tooltipPosition == 'top', 'right-0 -translate-y-1/2 top-1/2 h-2.5 -mt-px translate-x-full' : tooltipPosition == 'left', 'top-0 -translate-x-1/2 left-1/2 w-2.5 -translate-y-full' : tooltipPosition == 'bottom', 'left-0 -translate-y-1/2 top-1/2 h-2.5 -mt-px -translate-x-full' : tooltipPosition == 'right' }" class="absolute inline-flex items-center justify-center overflow-hidden">
                <div :class="{ 'origin-top-left -rotate-45' : tooltipPosition == 'top', 'origin-top-left rotate-45' : tooltipPosition == 'left', 'origin-bottom-left rotate-45' : tooltipPosition == 'bottom', 'origin-top-right -rotate-45' : tooltipPosition == 'right' }" class="w-1.5 h-1.5 transform bg-black bg-opacity-90"></div>
            </div>
        </div>
    </div>
    
    <div x-ref="content" class="px-3 py-1 text-xs rounded-full cursor-pointer text-neutral-500 bg-neutral-100">hover me</div>

</div>

To make this tooltip functionality re-useable we could extract it into its own plugin, like so:

<script>
    document.addEventListener('alpine:init', () => {
        
        Alpine.directive('tooltip', (el, { modifiers, expression }, { cleanup }) => {
            let tooltipText = expression;
            let tooltipArrow = modifiers.includes('noarrow') ? false : true;
            let tooltipPosition = 'top';
            let tooltipId = 'tooltip-' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
            let positions = ['top', 'bottom', 'left', 'right'];
            let elementPosition = getComputedStyle(el).position;

            for (let position of positions) {
                if (modifiers.includes(position)) {
                    tooltipPosition = position;
                    break;
                }
            }

            if(!elementPosition.includes(['relative', 'absolute', 'fixed'])){
                el.style.position='relative';
            }
            
            let tooltipHTML = `
                <div id="${tooltipId}" x-data="{ tooltipVisible: false, tooltipText: '${tooltipText}', tooltipArrow: ${tooltipArrow}, tooltipPosition: '${tooltipPosition}' }" x-ref="tooltip" x-init="setTimeout(function(){ tooltipVisible = true; }, 1);" x-show="tooltipVisible" :class="{ 'top-0 left-1/2 -translate-x-1/2 -mt-0.5 -translate-y-full' : tooltipPosition == 'top', 'top-1/2 -translate-y-1/2 -ml-1.5 left-0 -translate-x-full' : tooltipPosition == 'left', 'bottom-0 left-1/2 -translate-x-1/2 -mb-0.5 translate-y-full' : tooltipPosition == 'bottom', 'top-1/2 -translate-y-1/2 -mr-1.5 right-0 translate-x-full' : tooltipPosition == 'right' }" class="absolute w-auto text-sm" x-cloak>
                    <div x-show="tooltipVisible" x-transition class="relative px-2 py-1 text-white bg-black rounded bg-opacity-90">
                        <p x-text="tooltipText" class="flex-shrink-0 block text-xs whitespace-nowrap"></p>
                        <div x-ref="tooltipArrow" x-show="tooltipArrow" :class="{ 'bottom-0 -translate-x-1/2 left-1/2 w-2.5 translate-y-full' : tooltipPosition == 'top', 'right-0 -translate-y-1/2 top-1/2 h-2.5 -mt-px translate-x-full' : tooltipPosition == 'left', 'top-0 -translate-x-1/2 left-1/2 w-2.5 -translate-y-full' : tooltipPosition == 'bottom', 'left-0 -translate-y-1/2 top-1/2 h-2.5 -mt-px -translate-x-full' : tooltipPosition == 'right' }" class="absolute inline-flex items-center justify-center overflow-hidden">
                            <div :class="{ 'origin-top-left -rotate-45' : tooltipPosition == 'top', 'origin-top-left rotate-45' : tooltipPosition == 'left', 'origin-bottom-left rotate-45' : tooltipPosition == 'bottom', 'origin-top-right -rotate-45' : tooltipPosition == 'right' }" class="w-1.5 h-1.5 transform bg-black bg-opacity-90"></div>
                        </div>
                    </div>
                </div>
            `;
            
            el.dataset.tooltip = tooltipId;

            let mouseEnter = function(event){ 
                el.innerHTML += tooltipHTML;
            };

            let mouseLeave = function(event){
                document.getElementById(event.target.dataset.tooltip).remove();
            };
            
            el.addEventListener('mouseenter', mouseEnter);
            el.addEventListener('mouseleave', mouseLeave);

            cleanup(() => {
                el.removeEventListener('mouseenter', mouseEnter);
                el.removeEventListener('mouseleave', mouseLeave);
            })
        })
        
    })

</script>

<!-- Then, include a tooltip anwhere in your code -->
<div class="w-screen h-screen flex items-center justify-center">
    <div x-data x-tooltip.top="hello">
        hover me
    </div>
</div>

Now, you will be able to use the x-tooltip directive anywhere you want to include a tooltip.


Understanding how to build the plugin

First, we will want to create the directive. We can do so with the following line:

Alpine.directive('tooltip', (el, { modifiers, expression }, { cleanup }) => {

This will give us the ability to use the x-tooltip directive. Then, we want to define all our variables:

// The text that will be displayed in the tooltip
let tooltipText = expression;
// Hide the arrow if the directive has a .noarrow modifier
let tooltipArrow = modifiers.includes('noarrow') ? false : true;
// The default tooltip position will be set to 'top'
let tooltipPosition = 'top';
// We need to create a dynamic ID so that way we can reference the tooltip element when we want to add or remove it
let tooltipId = 'tooltip-' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
// These are all the possible positions for the tooltip
let positions = ['top', 'bottom', 'left', 'right'];
// We need to check the position of the element that the tooltip is attached to, we'll use this variable in a following step
let elementPosition = getComputedStyle(el).position;

Next we will loop through all the positions to see if the directive has any of the following modifiers `.top, .left, .bottom, .right`.

for (let position of positions) {
    if (modifiers.includes(position)) {
        tooltipPosition = position;
        break;
    }
}

This will allow modifiers to specify the position of the tooltip:

The reason that we stored the elementPosition above is because our tooltip has an absolute value, in order for the absolute position to work, the parent element position must be relative, absolute, or fixed. If it is none of those we will set the position to relative:

if(!elementPosition.includes(['relative', 'absolute', 'fixed'])){
    el.style.position='relative';
}

Then, we store the HTML of our tooltip so we can append it to the element when it's hovered.

let tooltipHTML = `
        <div id="${tooltipId}" x-data="{ tooltipVisible: false, tooltipText: '${tooltipText}', tooltipArrow: ${tooltipArrow}, tooltipPosition: '${tooltipPosition}' }" x-ref="tooltip" x-init="setTimeout(function(){ tooltipVisible = true; }, 1);" x-show="tooltipVisible" :class="{ 'top-0 left-1/2 -translate-x-1/2 -mt-0.5 -translate-y-full' : tooltipPosition == 'top', 'top-1/2 -translate-y-1/2 -ml-1.5 left-0 -translate-x-full' : tooltipPosition == 'left', 'bottom-0 left-1/2 -translate-x-1/2 -mb-0.5 translate-y-full' : tooltipPosition == 'bottom', 'top-1/2 -translate-y-1/2 -mr-1.5 right-0 translate-x-full' : tooltipPosition == 'right' }" class="absolute w-auto text-sm" x-cloak>
            <div x-show="tooltipVisible" x-transition class="relative px-2 py-1 text-white bg-black rounded bg-opacity-90">
                <p x-text="tooltipText" class="flex-shrink-0 block text-xs whitespace-nowrap"></p>
                <div x-ref="tooltipArrow" x-show="tooltipArrow" :class="{ 'bottom-0 -translate-x-1/2 left-1/2 w-2.5 translate-y-full' : tooltipPosition == 'top', 'right-0 -translate-y-1/2 top-1/2 h-2.5 -mt-px translate-x-full' : tooltipPosition == 'left', 'top-0 -translate-x-1/2 left-1/2 w-2.5 -translate-y-full' : tooltipPosition == 'bottom', 'left-0 -translate-y-1/2 top-1/2 h-2.5 -mt-px -translate-x-full' : tooltipPosition == 'right' }" class="absolute inline-flex items-center justify-center overflow-hidden">
                    <div :class="{ 'origin-top-left -rotate-45' : tooltipPosition == 'top', 'origin-top-left rotate-45' : tooltipPosition == 'left', 'origin-bottom-left rotate-45' : tooltipPosition == 'bottom', 'origin-top-right -rotate-45' : tooltipPosition == 'right' }" class="w-1.5 h-1.5 transform bg-black bg-opacity-90"></div>
                </div>
            </div>
        </div>
    `;

Next, we need to set the tooltip ID to the element's dataset so that way we can reference it later on.

el.dataset.tooltip = tooltipId;

When the user hovers the element we want to append the tooltip HTML, and when their mouse leaves the element we want to remove it:

let mouseEnter = function(event){ 
    el.innerHTML += tooltipHTML;
};

let mouseLeave = function(event){
    document.getElementById(event.target.dataset.tooltip).remove();
};

el.addEventListener('mouseenter', mouseEnter);
el.addEventListener('mouseleave', mouseLeave);

Finally, the last piece of code is using a helper callback function called cleanup() that is provided by AlpineJS. It will allow us to perform any cleanup functionality, such as removing event listeners, whenever the parent element is removed from the DOM:

cleanup(() => {
    el.removeEventListener('mouseenter', mouseEnter);
    el.removeEventListener('mouseleave', mouseLeave);
})

And that's how you would go about creating a simple Alpine plugin that can be re-used throughout your application.


Why not create Pines as a separate library

We wanted to build a set of UI elements that are highly customizable and flexible. These elements will allow developers to easily integrate them with any Tallstack application. If the demand is high enough, we may strongly consider converting Pines into it's own external library.

Be sure to let us know your thoughts, give us a Star on Github, and consider contributing to help steer the direction of this project.