Create an Alpine.js Custom Plugin: Step by Step
AI generated
x-data
Alpine
Alpine.js · Plugin Development · Directives · Magic Properties
Create an Alpine.js Custom Plugin
Directives, Magic Properties, and Stores

The Alpine.js plugin API lets you register your own directives like x-tooltip, magic properties like $format, and global stores, then reuse these extensions cleanly bundled across every Alpine project.

18 min read Alpine.plugin() · addDirective · addMagic · addStore · npm package Alpine.js 3.x · Hyvä Themes · Magento 2

1. Why an Alpine.js Plugin Instead of Alpine.data()?

Alpine.js provides Alpine.data() as a simple way to register reusable component logic. But what if you want to add behavior at the DOM level that isn't tied to a component? What if you need a new directive like x-tooltip or x-clipboard that can be attached to any element? Or a magic property like $format that's available in every x-data context? This is exactly what the Alpine.js plugin API is for.

At its core, a plugin is a function that receives Alpine as an argument and uses its internal API to register directives, magic properties, and stores. The pattern Alpine.plugin(MyPlugin) runs this function and integrates all extensions into the Alpine runtime. The key advantage over loose JavaScript: everything is cleanly encapsulated, tree shakeable, testable, and ready for npm. Official Alpine.js plugins like @alpinejs/focus, @alpinejs/persist, and @alpinejs/morph use exactly the same API that's available for custom plugins.

For Hyvä themes and Magento 2, custom plugins are especially valuable: instead of scattering project specific logic across individual .phtml files, you can consolidate it into a central plugin module that's loaded once and available everywhere. This significantly improves maintainability and reduces the chance of the same functionality being implemented inconsistently in multiple places.

2. The Alpine.js Plugin API: Understanding Alpine.plugin()

The plugin function receives the Alpine object and has access to all internal methods: Alpine.addDirective(name, callback) registers a new directive, Alpine.addMagic(name, callback) adds a magic property, Alpine.addStore(name, object) creates a reactive global store. There are also Alpine.onBeforeComponentInitialized and Alpine.onComponentInitialized hooks for the component lifecycle.

The plugin must be registered before Alpine.start(). With the CDN variant, that means loading the plugin script before the main Alpine script, or registering it inside an alpine:init event. With the npm variant, Alpine.plugin(MyPlugin) is called before Alpine.start(). When using multiple plugins, order matters if plugins depend on one another: dependencies must be registered first.


// Plugin structure: the function receives Alpine as argument
function MironsoftPlugin(Alpine) {

  // 1. Register a custom directive: x-autofocus
  Alpine.addDirective('autofocus', (el, { expression, modifiers }, { effect, evaluate, cleanup }) => {
    // Execute once on init, no expression needed
    const delay = modifiers.includes('delay') ? 100 : 0;
    const timer = setTimeout(() => {
      el.focus();
    }, delay);

    // Cleanup function, called when element is removed from DOM
    cleanup(() => clearTimeout(timer));
  });

  // 2. Register a magic property: $uid (unique ID generator)
  Alpine.addMagic('uid', (el) => {
    return (prefix = 'alpine') => {
      if (!el._alpineUid) {
        el._alpineUid = `${prefix}-${Math.random().toString(36).slice(2, 9)}`;
      }
      return el._alpineUid;
    };
  });

  // 3. Register a global reactive store: $store.theme
  Alpine.addStore('theme', {
    current: localStorage.getItem('theme') || 'light',
    toggle() {
      this.current = this.current === 'light' ? 'dark' : 'light';
      localStorage.setItem('theme', this.current);
    },
    get isDark() {
      return this.current === 'dark';
    }
  });
}

// Register plugin before Alpine.start()
document.addEventListener('alpine:init', () => {
  Alpine.plugin(MironsoftPlugin);
});

3. Building a Custom Directive with addDirective

The callback passed to addDirective receives three arguments: the DOM element el, an object with expression, value, and modifiers, and a utilities object with effect, evaluate, evaluateLater, and cleanup. The expression property contains the raw string counterpart of the attribute value, meaning whatever follows the colon; for x-tooltip="'Hello'" that would be 'Hello'. The evaluate(expression) function evaluates this expression within the context of the surrounding Alpine component.

For reactive directives, effect(() => { ... }) is the right building block. Everything inside effect reruns whenever the data it depends on changes, exactly how x-text or x-bind work internally. The cleanup function registers code that runs when the element is removed from the DOM, for example removing event listeners, clearing timers, or destroying instances of external libraries. Without cleanup, memory leaks arise, especially in single page applications or with dynamically rendered Hyvä components.

4. Registering Magic Properties with addMagic

Magic properties are available under the $ prefix in every Alpine component context. Alpine itself provides $el, $refs, $store, $dispatch, $nextTick, $watch, $root, and $data. With addMagic you can add your own properties that get the same access. The callback receives the current DOM element and can return a value, a function, or an object.

A typical example of a useful magic property: $format for number and date formatting. Instead of defining a formatting function in every component, it's registered once in the plugin and is then available everywhere as $format.currency(price) or $format.date(timestamp). This is especially valuable in Magento 2 / Hyvä, where prices, quantities, and dates need formatting across many components and the locale setting is known centrally.

5. Setting Up Global Stores via addStore

Global stores are Alpine.js's mechanism for application wide, reactive state. With Alpine.addStore('cart', { items: [], total: 0, addItem(item) { ... } }), a store is created that all components can access via $store.cart. Changes to store properties trigger reactive updates in every component that reads those properties, with no manual event emitters or pub/sub systems needed. Stores can contain getters, setters, and methods, and are fully reactive.

Inside plugins, addStore can be used to manage plugin internal configuration centrally. For example, a toast notification plugin could manage a store holding the notification queue, accessed both by the trigger method ($notify.success('...')) and by the rendering component (x-data="{ get notifications() { return $store.notifications.queue } }"). This fully decouples triggering notifications from the presentation logic.


// Advanced plugin: $format magic property + $notify magic method
function MironsoftFormatPlugin(Alpine) {

  // $format: locale-aware formatting utilities
  Alpine.addMagic('format', () => {
    const locale = document.documentElement.lang || 'de-DE';
    const currency = document.documentElement.dataset.currency || 'EUR';

    return {
      currency(value, options = {}) {
        return new Intl.NumberFormat(locale, {
          style: 'currency',
          currency,
          minimumFractionDigits: 2,
          ...options
        }).format(value);
      },

      number(value, decimals = 0) {
        return new Intl.NumberFormat(locale, {
          minimumFractionDigits: decimals,
          maximumFractionDigits: decimals
        }).format(value);
      },

      date(value, style = 'medium') {
        const date = value instanceof Date ? value : new Date(value);
        return new Intl.DateTimeFormat(locale, { dateStyle: style }).format(date);
      },

      relative(value) {
        const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
        const diff = (new Date(value) - Date.now()) / 1000;
        const units = [
          [60, 'second'], [3600, 'minute'], [86400, 'hour'], [Infinity, 'day']
        ];
        for (const [limit, unit] of units) {
          if (Math.abs(diff) < limit) {
            return rtf.format(Math.round(diff / (limit / 60)), unit);
          }
        }
      }
    };
  });
}
// Usage in template: x-text="$format.currency(product.price)"
// Usage: x-text="$format.date(order.createdAt)"

6. Plugin Lifecycle: init, bind, cleanup

Alpine.js directives have a clearly defined lifecycle. The callback passed to addDirective runs when the element is initialized. Reactive effects registered via effect() then rerun on every data change. The cleanup callback runs when the element is removed from the DOM, or when Alpine reinitializes the element (for example after an x-if toggle). This three stage lifecycle mirrors what's known in React as mount, update, and unmount.

A common mistake in custom plugins: external libraries (Popper.js, Chart.js, Flatpickr) get initialized inside the directive but never destroyed in the cleanup function. This leads to memory leaks and unexpected behavior with dynamically rendered elements. Any external library that offers a destroy method must have it called inside cleanup. The same applies to event listeners, ResizeObserver, MutationObserver, and IntersectionObserver: all of them must be cleanly unregistered in cleanup.

7. Practical Example: an x-tooltip Plugin with Popper.js

A tooltip plugin demonstrates every aspect of the plugin API working together. The x-tooltip directive accepts a reactive expression for the tooltip text, dynamically creates a tooltip element, positions it with Popper.js, and shows it on hover or focus of the element. The tooltip text updates reactively via effect whenever the expression changes. On cleanup, the Popper instance is destroyed and the tooltip element is removed from the DOM.

Using it in a template is refreshingly simple: <button x-tooltip="tooltipText">Hover me</button>. Modifiers like x-tooltip.bottom or x-tooltip.click control placement and trigger behavior. This API is far cleaner than inline JavaScript in the template and fully encapsulates the Popper.js dependency inside the plugin, keeping templates free of library specific details. The plugin can be parameterized with a configuration option passed into the plugin call: Alpine.plugin(TooltipPlugin({ defaultPlacement: 'top' })).


// x-tooltip plugin: wraps Popper.js with a clean Alpine directive
function TooltipPlugin(config = {}) {
  return function (Alpine) {
    Alpine.addDirective('tooltip', (el, { expression, modifiers }, { effect, evaluateLater, cleanup }) => {
      // Evaluate expression reactively
      const getText = evaluateLater(expression);

      // Create tooltip DOM element
      const tooltip = document.createElement('div');
      tooltip.setAttribute('role', 'tooltip');
      tooltip.className = 'tooltip-popup bg-slate-900 text-white text-xs rounded px-2 py-1 pointer-events-none';
      tooltip.style.display = 'none';
      document.body.appendChild(tooltip);

      // Determine placement from modifiers or config
      const placement = modifiers.find(m =>
        ['top', 'bottom', 'left', 'right'].includes(m)
      ) || config.defaultPlacement || 'top';

      // Init Popper (assume Popper.js is loaded separately)
      let popperInstance = null;
      const initPopper = () => {
        if (window.Popper) {
          popperInstance = window.Popper.createPopper(el, tooltip, {
            placement,
            modifiers: [{ name: 'offset', options: { offset: [0, 8] } }]
          });
        }
      };

      // Update tooltip text reactively
      effect(() => {
        getText(value => { tooltip.textContent = value; });
      });

      // Show/hide handlers
      const show = () => { tooltip.style.display = 'block'; initPopper(); };
      const hide = () => { tooltip.style.display = 'none'; };
      el.addEventListener('mouseenter', show);
      el.addEventListener('mouseleave', hide);
      el.addEventListener('focusin', show);
      el.addEventListener('focusout', hide);

      // Cleanup: destroy Popper and remove tooltip element
      cleanup(() => {
        popperInstance?.destroy();
        tooltip.remove();
        el.removeEventListener('mouseenter', show);
        el.removeEventListener('mouseleave', hide);
        el.removeEventListener('focusin', show);
        el.removeEventListener('focusout', hide);
      });
    });
  };
}
Alpine.plugin(TooltipPlugin({ defaultPlacement: 'bottom' }));

8. Publishing a Plugin as an npm Package

A well structured Alpine.js plugin can easily be published as an npm package so it can be used across projects. The package structure is simple: a main file exports the plugin function as the default export. A package.json defines main for CommonJS, module for ES modules, and exports for modern Node.js versions. Peer dependencies should declare alpinejs as a peer dependency to ensure the plugin is compatible with whatever Alpine version the project uses.

For Hyvä themes and Magento 2, the plugin can also be distributed as a PHP package via Composer if it contains static web assets that need to be deployed through Magento's asset system. Alternatively, the npm package is added as a dependency in the Hyvä theme's package.json and integrated into the Tailwind bundle during the build process. It's important that the plugin produces properly tree shakeable ESM code, so unused features don't end up in the bundle.

9. Comparing Plugin Strategies

Alpine.js offers several ways to encapsulate and reuse logic. Choosing the right strategy depends on the use case.

Strategy Use Case API Scope
Alpine.data() Reusable component logic x-data="myComponent()" Component instance
addDirective DOM behavior on arbitrary elements x-my-directive="..." Element + reactivity
addMagic Utility functions available everywhere $myHelper.method() All components
addStore Shared reactive state $store.myStore.prop Global, all components
Alpine.plugin() Bundle of directives + magic + stores Alpine.plugin(MyPlugin) Entire Alpine instance

The decision rule is pragmatic: if the logic is tied to a component, use Alpine.data(). If it directly manipulates DOM elements (external libraries, events, lifecycle), use a directive. If it's a utility function used inside expressions, use addMagic. If state needs to be shared between unrelated components, use a store. A plugin bundles all of this and makes the extension installable as a single unit.

Mironsoft

Alpine.js Plugin Development, Hyvä Themes, and Magento 2 Frontend

Need a Custom Alpine.js Plugin for Your Project?

We build tailor made Alpine.js plugins for Hyvä themes and Magento 2, from the directive through magic properties to a fully tested npm package.

Plugin Development

Directives, magic properties, and stores built to order for Hyvä and Magento 2

Code Review

Analyzing existing Alpine.js plugins: lifecycle, memory leaks, reactivity

npm Packaging

Preparing and publishing your plugin as a reusable ESM package

10. Summary

The Alpine.js plugin API lets you extend Alpine with your own directives, magic properties, and global stores, without patching Alpine internals or working around external dependencies. With Alpine.addDirective(), you get DOM native behavior extensions like x-tooltip or x-autofocus. With Alpine.addMagic(), utility functions like $format become available in every component context. With Alpine.addStore(), you get shared, reactive state with no boilerplate.

The key difference from Alpine.data(): plugins are composition friendly, tree shakeable, and npm ready. They can combine all three API building blocks into a single bundle and be installed as one unified extension. The lifecycle built around effect and cleanup inside directives ensures external libraries are correctly initialized and destroyed, with no memory leaks. For Hyvä themes and Magento 2, the plugin pattern is the cleanest way to standardize Alpine.js behavior across a project.

Alpine.js Custom Plugin: The Essentials at a Glance

Registering a Plugin

Call Alpine.plugin(MyPlugin) before Alpine.start(). The plugin function receives Alpine and registers directives, magic, and stores.

Directives

Alpine.addDirective('name', callback). The callback receives el, the binding object, and utilities. effect for reactivity, cleanup for memory management.

Magic Properties

Alpine.addMagic('name', callback), available everywhere as $name. Ideal for formatting functions, IDs, and utilities used inside expressions.

Cleanup Is Mandatory

Every external library, observer, and event listener must be destroyed inside cleanup(). Without cleanup, memory leaks occur in dynamic components.

11. FAQ: Creating an Alpine.js Custom Plugin

1Difference between Alpine.data() and a plugin?
Alpine.data() registers component logic. A plugin extends Alpine itself with directives, magic properties, and stores, not tied to a single component instance.
2When must Alpine.plugin() be called?
Before Alpine.start(). Safest inside the alpine:init event. With ESM, directly before Alpine.start() in the initialization code.
3evaluateLater vs. evaluate?
evaluate runs once. evaluateLater returns a function that evaluates reactively. Always use evaluateLater with effect().
4Passing configuration to a plugin?
Plugin as a factory function: function MyPlugin(config) { return function(Alpine) { ... } }. Then Alpine.plugin(MyPlugin({ option: 'value' })).
5What happens without cleanup()?
Memory leaks: external libraries, event listeners, and observers keep running even after the element is removed from the DOM.
6Plugin dependencies?
Register the dependency plugin first. Alpine.plugin(BasePlugin); then Alpine.plugin(ExtensionPlugin). Order matters.
7How do modifiers work in directives?
The binding object contains the modifiers array. For x-tooltip.bottom, modifiers equals ['bottom']. Evaluate with modifiers.includes().
8Include a plugin without npm?
Yes, as a global variable or via the alpine:init event. For Hyvä, place it in web/js/ and include it through layout XML or requirejs-config.js.
9How do you test an Alpine.js plugin?
Playwright for E2E tests. @alpinejs/testing-utils for unit tests. Internal plugin functions can be tested directly without loading the Alpine runtime.
10Extend an existing store from a plugin?
Alpine.store('name') reads the store. Better: create your own store instead of modifying someone else's. Prevents unexpected side effects across plugin updates.