Most things in Vue are components. A directive is for the cases where a component is too much: the element already exists, and you only want to do something to it. v-model and v-show are directives Vue ships, and you can write your own.
You will write a typed v-focus and a v-click-outside that removes its listener on unmount, install both with one app.use() call, and learn when a composable or a component is the better tool.
Directive hooks and what they receive
A directive is an object of lifecycle hooks, and each hook receives the element. The hooks mirror a component's: created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount and unmounted. All of them are optional; most directives use two or three.
Each hook gets el, the DOM element, and binding, an object that carries what the template passed in. For <div v-example:foo.bar="baz">:
binding.valueis the value ofbazbinding.argis'foo'binding.modifiersis{ bar: true }binding.oldValue, inbeforeUpdateandupdatedonly, is the previous value
Vue's guide asks you to treat everything except el as read-only. If a hook needs to remember something for a later hook, store it against the element, not on binding.
Focus an input on mount with v-focus
In <script setup>, any variable whose name starts with v followed by a capital letter is available in the template as a directive. vFocus becomes v-focus.
<script setup lang="ts">
import type { Directive } from 'vue'
const vFocus: Directive<HTMLInputElement> = {
mounted: (el) => el.focus(),
}
</script>
<template>
<input v-focus type="search" placeholder="Search lessons" />
</template>
Directive<HTMLInputElement> types el, so TypeScript accepts el.focus(), and el.select() would pass too. There is nothing to clean up: focus is a one-off action at mount. Compare that with the usual alternative, a template ref plus an onMounted that calls focus(). Three lines become one attribute, and the attribute reads as intent.
v-click-outside: arguments, updates and cleanup
Closing a dropdown when the user clicks anywhere else is the classic directive. It needs a listener on document, which means it also needs to remove that listener, and it should keep working if the component passes a new callback. The second type parameter of Directive types binding.value.
import type { Directive } from 'vue'
interface Entry {
callback: () => void
eventName: string
handler: (event: Event) => void
}
const entries = new WeakMap<HTMLElement, Entry>()
export const vClickOutside: Directive<HTMLElement, () => void> = {
mounted(el, binding) {
const entry: Entry = {
callback: binding.value,
eventName: binding.arg ?? 'click',
handler: (event) => {
if (!el.contains(event.target as Node)) entries.get(el)?.callback()
},
}
entries.set(el, entry)
document.addEventListener(entry.eventName, entry.handler, true)
},
updated(el, binding) {
const entry = entries.get(el)
if (entry) entry.callback = binding.value
},
unmounted(el) {
const entry = entries.get(el)
if (entry) document.removeEventListener(entry.eventName, entry.handler, true)
entries.delete(el)
},
}
Four details carry the weight:
- The
WeakMapkeyed by the element keeps the directive's state between hooks. The guide suggestsdataset, which holds only strings; this directive stores functions. AWeakMapalso lets the browser garbage-collect the element and its entry together. - The argument,
binding.arg, chooses the event:v-click-outside:pointerdownreacts when the pointer goes down, before a click completes. With no argument it listens forclick. - The capture phase (the
truepassed toaddEventListener) makes the listener ondocumentrun before the target element's own handlers, so it still fires when one of them stops propagation. updatedswaps only the callback. A parent that passes a new inline arrow function on every render does not re-register anything.
In a component, it closes a menu:
<script setup lang="ts">
import { ref } from 'vue'
import { vClickOutside } from '../directives/clickOutside'
const open = ref(false)
</script>
<template>
<div v-click-outside:pointerdown="() => (open = false)">
<button type="button" @click="open = !open">Menu</button>
<ul v-if="open">
<li><a href="/lessons">Lessons</a></li>
</ul>
</div>
</template>
Importing vClickOutside into <script setup> is enough; the v prefix makes it a directive in this template. The wrapper div contains the button, so pressing the button counts as inside and only its @click changes open.
The mistake: a listener that outlives its element
Here is the version people write first.
export const vClickOutside: Directive<HTMLElement, () => void> = {
mounted(el, binding) {
document.addEventListener('click', (event) => {
if (!el.contains(event.target as Node)) binding.value()
})
},
}
It works until the dropdown unmounts. The listener stays on document, holding a reference to a detached element and a callback into a component that no longer exists. Every time a dropdown mounts, one more is added, so after a while each click runs a dozen dead handlers. Vue prints no warning when one of them sets state on the unmounted component, so the leak is silent. And because the function was never stored, nothing can pass it to removeEventListener later.
The fix is the full version above without the argument and updated. It keeps the handler in the entries map, so unmounted can find it again:
export const vClickOutside: Directive<HTMLElement, () => void> = {
mounted(el, binding) {
const handler = (event: Event) => {
if (!el.contains(event.target as Node)) binding.value()
}
entries.set(el, { callback: binding.value, eventName: 'click', handler })
document.addEventListener('click', handler, true)
},
unmounted(el) {
const entry = entries.get(el)
if (entry) document.removeEventListener('click', entry.handler, true)
entries.delete(el)
},
}
Whatever a directive adds in mounted, it removes in unmounted. To remove a listener, pass removeEventListener the same function and the same capture flag you gave addEventListener.
Register a directive locally or globally
A directive declared or imported inside <script setup> is local to that component, and that is the right default. A directive used in many components can be registered once on the app with a kebab-case name.
import { createApp } from 'vue'
import App from './App.vue'
import { vClickOutside } from './directives/clickOutside'
const app = createApp(App)
app.directive('click-outside', vClickOutside)
app.mount('#app')
Global directives are available in every template without an import. The price is that nothing in a component's file tells a reader where v-click-outside comes from, and TypeScript does not know about it either. To type it, add it to the GlobalDirectives interface from vue under its v-prefixed name, at the end of the directive's file:
declare module 'vue' {
interface GlobalDirectives {
vClickOutside: typeof vClickOutside
}
}
Then vue-tsc rejects a v-click-outside value that is not a function. For a handful of directives used everywhere, the trade is fine. For one used in two components, import it.
Ship directives and options in a plugin
A plugin is an object with an install(app, options) method (or just a function that does the same job), and app.use(plugin, options) calls it. Inside, it can register components and directives, provide values for inject, and add global properties. Here is one that installs both directives and provides a settings object. Its vFocus is the one from SearchBox.vue, moved to src/directives/focus.ts and exported; the server section below shows that file, with one hook added.
import type { InjectionKey, Plugin } from 'vue'
import { vClickOutside } from '../directives/clickOutside'
import { vFocus } from '../directives/focus'
export interface UiOptions {
closeOnEscape: boolean
}
export const uiOptionsKey: InjectionKey<UiOptions> = Symbol('ui-options')
export const uiPlugin: Plugin<UiOptions> = {
install(app, options) {
app.directive('focus', vFocus)
app.directive('click-outside', vClickOutside)
app.provide(uiOptionsKey, options)
},
}
Plugin<UiOptions> types the second argument of install and, through it, the second argument of app.use(). In src/main.ts, app.use(uiPlugin, { closeOnEscape: true }) replaces the app.directive() call, and passing the wrong shape, or no options at all, is a compile error.
InjectionKey<UiOptions> types the other end. A component that calls inject(uiOptionsKey) gets UiOptions | undefined, because nothing guarantees the plugin was installed. With a default, inject(uiOptionsKey, { closeOnEscape: true }) gets UiOptions.
Use provide for values like these, and keep app.config.globalProperties for the rare helper that every template needs. Vue's plugins guide warns that too many global properties quickly make an app confusing.
Render a directive on the server with getSSRProps
A directive's hooks touch the DOM, and there is no DOM during server-side rendering, so Vue ignores them on the server. For v-click-outside that is fine; nobody clicks a server. For v-focus it means the server's HTML has no hint about focus until hydration, when Vue's JavaScript takes over the page in the browser. The getSSRProps hook fills that gap: it receives the binding and returns attributes to render on the server.
import type { Directive } from 'vue'
export const vFocus: Directive<HTMLElement> = {
mounted: (el) => el.focus(),
getSSRProps: () => ({ autofocus: true }),
}
The server now renders <input autofocus>, so on the first page load the browser can focus the field before hydration. mounted still runs after hydration, and it still matters: Vue's guide notes that, unlike autofocus, the directive also works when Vue inserts the element after the page has loaded. In a Nuxt app this is the version to use.
Choose a composable, a directive or a component
The deciding question is what the behaviour needs.
If it needs state, reactivity or a return value, write a composable. A directive cannot return anything to the template, and reactive state inside its hooks has nowhere to go. VueUse ships both shapes of the click-outside behaviour, which tells you both have a place: the composable onClickOutside(target, handler) in @vueuse/core, and the directive vOnClickOutside in @vueuse/components.
If it needs markup, write a component. A directive can only alter an element that already exists; it cannot add a wrapper, a label or a slot.
If it is "do this to that element" with no state and no markup, write a directive: focus, click outside, resize-aware classes, tooltips attached to arbitrary elements, lazy-loaded images. The template stays declarative and the behaviour travels as one attribute.