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.
Table of Contents
- 1. Why an Alpine.js Plugin Instead of Alpine.data()?
- 2. The Alpine.js Plugin API: Understanding Alpine.plugin()
- 3. Building a Custom Directive with addDirective
- 4. Registering Magic Properties with addMagic
- 5. Setting Up Global Stores via addStore
- 6. Plugin Lifecycle: init, bind, cleanup
- 7. Practical Example: an x-tooltip Plugin with Popper.js
- 8. Publishing a Plugin as an npm Package
- 9. Comparing Plugin Strategies
- 10. Summary
- 11. FAQ
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.