Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Custom Directives & Plugins

Writing v-focus-style directives and installable plugins with app.use().

Components reuse UI, and composables reuse state and logic. Two more extension points cover the rest:

  • A custom directive reuses low-level DOM behaviour on any element: focus it, detect clicks outside it, attach a tooltip, lazy-load an image.
  • A plugin packages app-wide features (components, directives, helpers, configuration) so one app.use() line installs them. Vue Router and Pinia are both plugins.

Directives: hooks on a DOM element

A directive is an object with lifecycle hooks. Each hook receives the actual DOM element, which is the one place in Vue where touching the DOM directly is the intended approach:

const myDirective = {
    created(el, binding, vnode) {},     // before attributes and listeners are applied
    beforeMount(el, binding) {},
    mounted(el, binding) {},            // element is in the document: the most used hook
    beforeUpdate(el, binding) {},
    updated(el, binding) {},            // the component re-rendered
    beforeUnmount(el, binding) {},
    unmounted(el, binding) {},          // clean up listeners here
};

binding describes how the directive was used:

Usage binding.value binding.arg binding.modifiers
v-tooltip="'Save'" 'Save' undefined {}
v-tooltip:bottom="msg" value of msg 'bottom' {}
v-highlight:word.case="term" value of term 'word' { case: true }

In <script setup>, any variable named vSomething is automatically available as v-something in the template. To use a directive everywhere, register it globally with app.directive('focus', vFocus).

v-focus

The classic first directive: focus an input as soon as it appears.

// directives/focus.js
export const vFocus = {
    mounted: (el) => el.focus(),
};
<script setup>
import { vFocus } from '@/directives/focus';
</script>

<template>
    <input v-if="adding" v-focus v-model="title" placeholder="New task">
</template>

When an object would only have mounted and updated doing the same thing, a directive can be a plain function: const vFocus = (el) => el.focus().

v-click-outside

Dropdowns, popovers and menus all need "close when the user clicks elsewhere". As a directive it's written once:

// directives/clickOutside.js
export const vClickOutside = {
    mounted(el, binding) {
        el._clickOutside = (event) => {
            if (!el.contains(event.target)) binding.value(event);   // value is the handler
        };
        document.addEventListener('click', el._clickOutside);
    },
    unmounted(el) {
        document.removeEventListener('click', el._clickOutside);   // always clean up
    },
};
<div class="filters" v-click-outside="() => (open = false)">
    <button @click="open = !open">Filters</button>
    <div v-if="open" class="popover">…</div>
</div>

The handler is stored on the element (el._clickOutside) so unmounted can remove the same function. Forgetting this leaks a listener for every element that ever used the directive.

v-tooltip with an argument

A tooltip directive reads its text from the value, and an optional argument picks the position:

// directives/tooltip.js
export const vTooltip = {
    mounted(el, binding) {
        let tip = null;
        el._tooltipText = binding.value;

        const show = () => {
            tip = Object.assign(document.createElement('div'), {
                className: 'v-tooltip', role: 'tooltip', textContent: el._tooltipText,
            });
            document.body.appendChild(tip);
            const box = el.getBoundingClientRect();
            const below = binding.arg === 'bottom';
            tip.style.left = `${box.left + box.width / 2 - tip.offsetWidth / 2}px`;
            tip.style.top = `${below ? box.bottom + 8 : box.top - tip.offsetHeight - 8}px`;
        };
        const hide = () => { tip?.remove(); tip = null; };

        el._tooltipHide = hide;
        el.addEventListener('mouseenter', show);
        el.addEventListener('mouseleave', hide);
        el.addEventListener('focus', show);     // keyboard users get tooltips too
        el.addEventListener('blur', hide);
    },
    updated(el, binding) { el._tooltipText = binding.value; },   // keep the text current
    unmounted(el) { el._tooltipHide(); },
};
<button v-tooltip="'Saves without closing the form'">Save</button>
<button v-tooltip:bottom="statusMessage">Status</button>

All three directives, live:

Arguments and modifiers: v-highlight

Arguments and modifiers let one directive cover several variations, just like the built-in v-on:click.prevent. This one highlights a search term, with :word for whole words only and .case for case-sensitive matching:

<p v-highlight="term">…</p>                 <!-- any match, any case -->
<p v-highlight:word="term">…</p>            <!-- whole words only -->
<p v-highlight:word.case="term">…</p>       <!-- whole words, exact case -->
function highlight(el, binding) {
    const term = binding.value ?? '';
    const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const pattern = binding.arg === 'word' ? `\\b${escaped}\\b` : escaped;
    const flags = binding.modifiers.case ? 'g' : 'gi';
    // …split el.textContent on new RegExp(pattern, flags) and wrap matches in <span class="highlight">
}

export const vHighlight = {
    mounted(el, binding) { el._original = el.textContent; highlight(el, binding); },
    updated: highlight,
};

Type task or Task and try the options. Notice "Multitasking" with and without :word:

Reach for a directive only for DOM behaviour. If you're computing data or rendering markup, a component or composable is the better tool, and both are easier to test.

Element hooks without a directive

A function passed to :ref runs with the element when it mounts, and with null when it's removed. That's a quick way to try directive-style DOM code before turning it into a directive. Toggle the input, then change focus() to select() and add some default text:

Plugins: installable features

A plugin is an object with an install(app, options) method (or just a function). Inside, you can do anything you'd do to the app in main.js:

In install() Makes available
app.component('Name', Comp) A component in every template, without importing it
app.directive('name', dir) A directive in every template
app.provide(key, value) A value for inject(key) in any component's setup()
app.config.globalProperties.$x = … $x in every template (the Options API's this.$x)

Here's a formatting plugin that bundles currency and date helpers, a PriorityBadge component and the tooltip directive, configured with a locale and currency:

// plugins/format.js
import PriorityBadge from '@/components/PriorityBadge.vue';
import { vTooltip } from '@/directives/tooltip';

export default {
    install(app, { locale = 'en-GB', currency = 'GBP' } = {}) {
        const format = {
            money: (amount) => new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount),
            date: (value) => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date(value)),
            relative: (value) => {
                const days = Math.round((new Date(value) - new Date()) / 86_400_000);
                return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(days, 'day');
            },
        };

        app.provide('format', format);                   // const format = inject('format')
        app.config.globalProperties.$format = format;    // {{ $format.money(total) }}
        app.component('PriorityBadge', PriorityBadge);
        app.directive('tooltip', vTooltip);
    },
};
// main.js
import { createApp } from 'vue';
import App from './App.vue';
import FormatPlugin from './plugins/format';

createApp(App)
    .use(FormatPlugin, { locale: 'en-GB', currency: 'GBP' })
    .mount('#app');

Change the options to re-install the plugin and watch every formatted value follow:

Prefer provide / inject over globalProperties in Composition API code. inject('format') is explicit about where the value comes from and is easy to replace in tests. globalProperties exists mainly so Options API components can use this.$format.

Plugins you already use

Once you've written one, the plugins you install every day stop being magic:

app.use(router);                 // registers <RouterLink>, <RouterView>, provides the router and $route
app.use(createPinia());          // provides the store registry that every useXStore() injects
app.use(i18n);                   // vue-i18n: provides translations, adds $t() to templates

Key takeaways

  • Directives give reusable DOM behaviour through element lifecycle hooks: mounted, updated and unmounted cover most needs.
  • binding.value, binding.arg and binding.modifiers let one directive handle variations like v-tooltip:bottom or v-highlight:word.case.
  • Always remove listeners in unmounted. Store the handler on the element so you can remove the same function.
  • In <script setup>, a vName variable is usable as v-name. Register app-wide ones with app.directive().
  • A plugin is an install(app, options) function that registers components, directives and provided values in one app.use() call.
  • Router, Pinia and i18n are all plugins built the same way.