Custom Directives and Plugins

Intermediate
11 min

Custom Directives and Plugins

Vue's built-in directives cover most template needs, but sometimes you need reusable low-level DOM behavior: auto-focusing an input, detecting clicks outside an element, lazy-loading images. Custom directives package that behavior behind a v-name attribute. Plugins operate one level higher: they add global functionality (components, directives, injected services) to the whole application in one app.use() call. After this lesson you will write both and know when each is appropriate.

Anatomy of a Custom Directive

A directive is an object whose keys are lifecycle hooks. Each hook receives the element and a binding object:

javascript
const vHighlight = { created(el, binding) {}, // before the element's attributes are applied beforeMount(el, binding) {}, mounted(el, binding) { // element is in the DOM el.style.background = binding.value ?? 'yellow' }, beforeUpdate(el, binding) {}, updated(el, binding) { // after the component re-rendered el.style.background = binding.value ?? 'yellow' }, beforeUnmount(el, binding) {}, unmounted(el, binding) {} // clean up listeners here }

binding contains value (the expression result), oldValue (in beforeUpdate/updated), arg (v-highlight:color), modifiers (v-highlight.once gives { once: true }) and instance (the component using it). Passing an object literal is the way to send several values: v-tooltip="{ text: 'Save', position: 'top' }".

When only mounted and updated matter and share the same logic, pass a function instead of an object; it runs for both hooks.

Registering Directives

In <script setup>, any variable whose name starts with v followed by a capital letter is usable as a directive in the template, so const vFocus = { mounted: (el) => el.focus() } enables <input v-focus />. Directives imported from another file follow the same rule: import { vClickOutside } from '@/directives/clickOutside'.

For app-wide availability, register on the application instance:

javascript
// main.js import { createApp } from 'vue' import App from './App.vue' import { vClickOutside } from './directives/clickOutside' const app = createApp(App) app.directive('click-outside', vClickOutside) // used as v-click-outside app.mount('#app')

A complete, cleanup-aware example:

javascript
export const vClickOutside = { mounted(el, binding) { el._onClick = (event) => { if (!el.contains(event.target)) binding.value(event) } document.addEventListener('click', el._onClick) }, unmounted(el) { document.removeEventListener('click', el._onClick) } }

Storing the handler on the element is the standard way to reach it again in unmounted. Directives on a component with multiple root nodes are ignored with a warning. Keep directives for direct DOM manipulation; anything involving state or rendering belongs in a component or composable.

Writing a Plugin

A plugin is an object with an install(app, options) method (or simply a function with that signature). Inside it you can do anything the app instance allows:

javascript
// src/plugins/i18n.js export default { install(app, options) { app.config.globalProperties.$t = (key) => options.messages[key] ?? key app.provide('i18n', options) app.component('LocaleSwitcher', LocaleSwitcher) app.directive('t', (el, binding) => { el.textContent = options.messages[binding.value] }) } }
javascript
// main.js import i18n from './plugins/i18n' app.use(i18n, { messages: { hello: 'Hola', bye: 'Adiós' } })

app.use calls install once, even if invoked twice with the same plugin. Templates can now use {{ $t('hello') }}, components can inject('i18n'), and <LocaleSwitcher> is registered everywhere. Vue Router and Pinia are plugins built exactly this way.

globalProperties vs provide

| app.config.globalProperties | app.provide | | --- | --- | | Available as $name in templates and this.$name in Options API | Retrieved with inject(key) in setup | | Not typed by default; augment ComponentCustomProperties in TypeScript | Typed via InjectionKey<T> | | Convenient for template helpers such as $t, $formatDate | Preferred for services and Composition API code |

Many plugins offer both, plus a composable (useI18n()) that wraps inject for the best developer experience.

Tips

  • Export directives as vName constants so <script setup> picks them up automatically.
  • Keep directives idempotent: updated runs often, so never add listeners there.
  • Give plugins an options object with sensible defaults.
Quick Quiz
Question 1 of 2

Where should a directive remove a document-level event listener it added in `mounted`?

Key Takeaways

  • A custom directive is an object of lifecycle hooks (mounted, updated, unmounted, ...) receiving el and binding.
  • In <script setup>, a vName variable is automatically usable as v-name; app.directive registers globally.
  • Always clean up listeners in unmounted, and keep directives for DOM-level behavior only.
  • A plugin exposes install(app, options) and can register components, directives, provided values and global properties.
  • app.use installs a plugin once; Vue Router and Pinia follow this same pattern.

Next lesson: script setup with TypeScript — type props, emits, refs and models for full editor support and safer components.

Custom Directives and Plugins - Vue.js | CodeYourCraft | CodeYourCraft