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.
A directive is an object whose keys are lifecycle hooks. Each hook receives the element and a binding object:
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.
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:
// 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:
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.
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:
// 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] })
}
}// 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.
| 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.
vName constants so <script setup> picks them up automatically.updated runs often, so never add listeners there.Where should a directive remove a document-level event listener it added in `mounted`?
mounted, updated, unmounted, ...) receiving el and binding.<script setup>, a vName variable is automatically usable as v-name; app.directive registers globally.unmounted, and keep directives for DOM-level behavior only.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.