Custom Directives
A custom directive attaches behaviour to a DOM element: seven hooks, the element itself, a binding object, and cleanup when it goes.
app.directive('focus', { mounted(el, binding, vnode) {}, updated(el, binding, vnode, prevVnode) {} })A directive is a template attribute that starts with v- and attaches behaviour to the element it sits on. v-if, v-for and v-model are built in. A custom directive is one you write: an object of hooks that Vue calls with the real DOM element. Use one when the job is about the element, not about markup.
The hooks and what they receive
There are seven: created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount and unmounted. Each is called as (el, binding, vnode). beforeUpdate and updated get a fourth argument, prevVnode, the vnode from the previous render. el is the DOM element. binding is an object with value, oldValue (set only in the two update hooks), arg, modifiers, instance and dir. So v-reveal:top.once="0.5" gives arg of 'top', modifiers of { once: true } and value of 0.5.
Shorthand and longhand
Passing a function instead of an object is shorthand for the same behaviour in mounted and updated. These two registrations are the same directive:
app.directive('color', (el, binding) => { el.style.color = binding.value })
app.directive('color', {
mounted(el, binding) { el.style.color = binding.value },
updated(el, binding) { el.style.color = binding.value }
})
Naming and registration
Inside <script setup>, a variable named with a v prefix is usable as a directive in that file's template: const vFocus becomes v-focus. For the whole app, call app.directive('focus', definition) on the app instance.
The example is a dropdown. A button toggles an account menu; a mousedown anywhere outside the open list closes it. Cleanup is the part people skip: a document listener added in mounted must be removed in unmounted, because the element is gone but the handler is not.
Example
<script setup lang="ts">
import { ref } from 'vue'
import type { Directive } from 'vue'
const listeners = new WeakMap<HTMLElement, (event: MouseEvent) => void>()
const vClickOutside: Directive<HTMLElement, () => void> = {
mounted(el, binding) {
const onDown = (event: MouseEvent) => {
if (!el.contains(event.target as Node)) {
binding.value()
}
}
listeners.set(el, onDown)
document.addEventListener('mousedown', onDown)
},
unmounted(el) {
const onDown = listeners.get(el)
if (onDown) {
document.removeEventListener('mousedown', onDown)
listeners.delete(el)
}
}
}
const open = ref(false)
</script>
<template>
<div class="menu">
<button type="button" @click="open = !open">Account</button>
<ul v-if="open" v-click-outside="() => (open = false)">
<li>Profile</li>
<li>Sign out</li>
</ul>
</div>
</template>
<style scoped>
.menu {
position: relative;
}
</style>When to use it
- Close a dropdown or dialog when a click lands outside it.
- Move focus into the first field of a form as soon as it appears.
- Attach an IntersectionObserver or ResizeObserver to an element without wrapping it in a component.
- Wire a third-party DOM library, such as an input mask, onto plain inputs across an app.
More examples
Autofocus, declared locally
The smallest useful directive. The field is focused as soon as the form appears. mounted is the right hook because it runs after the element has been inserted into its parent container; created runs before the element's attributes and listeners are applied, and beforeMount runs before insertion, so focus() there has nothing to focus. Declared in <script setup> as vAutofocus, it is private to this component, which is where most directives should start.
<script setup lang="ts">
import type { Directive } from 'vue'
const vAutofocus: Directive<HTMLInputElement> = {
mounted(el) {
el.focus()
}
}
</script>
<template>
<form @submit.prevent>
<label for="q">Search</label>
<input id="q" v-autofocus type="search" name="q" />
</form>
</template>Global registration: function shorthand beside the object longhand
app.directive(name, definition) makes a directive available in every template in that app, and the name is given without the v- prefix. The two definitions above behave identically: a function definition is called for mounted and for updated, and for nothing else. Reach for the function form only when those two hooks really are all you need - the moment you need unmounted for cleanup, you need the object.
// src/main.ts
import { createApp } from 'vue'
import type { Directive } from 'vue'
import App from './App.vue'
// Shorthand: a plain function.
const colorShorthand: Directive<HTMLElement, string> = (el, binding) => {
el.style.color = binding.value
}
// Longhand: the same directive, written out.
const colorLonghand: Directive<HTMLElement, string> = {
mounted(el, binding) {
el.style.color = binding.value
},
updated(el, binding) {
el.style.color = binding.value
}
}
const app = createApp(App)
app.directive('color', colorShorthand)
app.directive('color-longhand', colorLonghand)
app.mount('#app')Argument, modifiers, and a value that may be absent
Reach for this shape when an element needs to know whether it is on screen. binding.modifiers is an object of the dot-suffixed flags, so .once arrives as { once: true }; the value is the threshold. Note the value type is number | undefined, because a directive can be used with no value at all - v-reveal on its own is legal, and binding.value is then undefined. The observer is disconnected in unmounted, or it keeps a reference to a detached element for the life of the page.
<script setup lang="ts">
import type { Directive } from 'vue'
const observers = new WeakMap<Element, IntersectionObserver>()
const vReveal: Directive<HTMLElement, number | undefined> = {
mounted(el, binding) {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
el.classList.toggle('is-visible', entry.isIntersecting)
if (entry.isIntersecting && binding.modifiers.once) {
observer.disconnect()
}
}
},
{ threshold: binding.value ?? 0.5 }
)
observer.observe(el)
observers.set(el, observer)
},
unmounted(el) {
observers.get(el)?.disconnect()
observers.delete(el)
}
}
</script>
<template>
<section v-reveal.once="0.25">
<h2>Pricing</h2>
</section>
</template>
<style scoped>
section {
opacity: 0;
transition: opacity 200ms;
}
section.is-visible {
opacity: 1;
}
</style>
Discussion