---
title: "Custom directives and plugins: when a component is not the answer · vuejs.school"
description: "Write v-focus and v-click-outside with typed hooks, register them locally and globally, ship them in a plugin, and learn when a composable is the better tool."
canonical_url: "https://vuejs.school/community/blog/custom-directives-and-plugins"
meta:
  "og:description": "Write v-focus and v-click-outside with typed hooks, register them locally and globally, ship them in a plugin, and learn when a composable is the better tool."
  "og:image": "https://vuejs.school/_og/d/a_~Q3VzdG9tIGRpcmVjdGl2ZXMgYW5kIHBsdWdpbnM6IHdoZW4gYSBjb21wb25lbnQgaXMgbm90IHRoZSBhbnN3ZXIgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~Q3VzdG9tIGRpcmVjdGl2ZXMgYW5kIHBsdWdpbnM6IHdoZW4gYSBjb21wb25lbnQgaXMgbm90IHRoZSBhbnN3ZXI%2Cdescription_~V3JpdGUgdi1mb2N1cyBhbmQgdi1jbGljay1vdXRzaWRlIHdpdGggdHlwZWQgaG9va3MsIHJlZ2lzdGVyIHRoZW0gbG9jYWxseSBhbmQgZ2xvYmFsbHksIHNoaXAgdGhlbSBpbiBhIHBsdWdpbiwgYW5kIGxlYXJuIHdoZW4gYSBjb21wb3NhYmxlIGlzIHRoZSBiZXR0ZXIgdG9vbC4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jdXN0b20tZGlyZWN0aXZlcy1hbmQtcGx1Z2lucyI%2Cs_y2qsFbDJ_Rp24jCT.png"
  "og:image:alt": "Custom directives and plugins: when a component is not the answer · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Custom directives and plugins: when a component is not the answer · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~Q3VzdG9tIGRpcmVjdGl2ZXMgYW5kIHBsdWdpbnM6IHdoZW4gYSBjb21wb25lbnQgaXMgbm90IHRoZSBhbnN3ZXIgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~Q3VzdG9tIGRpcmVjdGl2ZXMgYW5kIHBsdWdpbnM6IHdoZW4gYSBjb21wb25lbnQgaXMgbm90IHRoZSBhbnN3ZXI%2Cdescription_~V3JpdGUgdi1mb2N1cyBhbmQgdi1jbGljay1vdXRzaWRlIHdpdGggdHlwZWQgaG9va3MsIHJlZ2lzdGVyIHRoZW0gbG9jYWxseSBhbmQgZ2xvYmFsbHksIHNoaXAgdGhlbSBpbiBhIHBsdWdpbiwgYW5kIGxlYXJuIHdoZW4gYSBjb21wb3NhYmxlIGlzIHRoZSBiZXR0ZXIgdG9vbC4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jdXN0b20tZGlyZWN0aXZlcy1hbmQtcGx1Z2lucyI%2Cs_y2qsFbDJ_Rp24jCT.png"
  "twitter:image:alt": "Custom directives and plugins: when a component is not the answer · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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.value` is the value of `baz`
- `binding.arg` is `'foo'`
- `binding.modifiers` is `{ bar: true }`
- `binding.oldValue`, in `beforeUpdate` and `updated` only, is the previous value

[Vue's guide](https://vuejs.org/guide/reusability/custom-directives.html#hook-arguments) 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`.

*src/components/SearchBox.vue*

```
<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`.

*src/directives/clickOutside.ts*

```
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 `WeakMap` keyed by the element** keeps the directive's state between hooks. The guide suggests `dataset`, which holds only strings; this directive stores functions. A `WeakMap` also lets the browser garbage-collect the element and its entry together.
- **The argument**, `binding.arg`, chooses the event: `v-click-outside:pointerdown` reacts when the pointer goes down, before a click completes. With no argument it listens for `click`.
- **The capture phase** (the `true` passed to `addEventListener`) makes the listener on `document` run before the target element's own handlers, so it still fires when one of them stops propagation.
- **`updated` swaps 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:

*src/components/DropdownMenu.vue*

```
<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.

*src/directives/clickOutside.ts**Breaks***

```
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:

*src/directives/clickOutside.ts**Works***

```
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.

*src/main.ts*

```
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:

*src/directives/clickOutside.ts*

```
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.

*src/plugins/ui.ts*

```
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](https://vuejs.org/guide/reusability/plugins.html) 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](https://vuejs.org/guide/scaling-up/ssr.html#custom-directives). 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.

*src/directives/focus.ts*

```
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.

**Check it stuck**3 questions from the Custom directives and plugins quiz.

[**Practice now**](https://vuejs.school/learn/advanced/custom-directives-and-plugins?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 7 min read**Vue 3.6 and Vapor Mode: where things stand**](https://vuejs.school/community/blog/vue-3-6-and-vapor-mode-where-things-stand) [Deep dive · 7 min read**Vapor Mode, explained without the hype**](https://vuejs.school/community/blog/vapor-mode-explained-without-the-hype)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/custom-directives-and-plugins#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-11-27","description":"Write v-focus and v-click-outside with typed hooks, register them locally and globally, ship them in a plugin, and learn when a composable is the better tool.","name":"Custom directives and plugins: when a component is not the answer","url":"https://vuejs.school/community/blog/custom-directives-and-plugins","breadcrumb":{"@id":"https://vuejs.school/community/blog/custom-directives-and-plugins#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/custom-directives-and-plugins"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/custom-directives-and-plugins#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-11-27","description":"Write v-focus and v-click-outside with typed hooks, register them locally and globally, ship them in a plugin, and learn when a composable is the better tool.","headline":"Custom directives and plugins: when a component is not the answer","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~Q3VzdG9tIGRpcmVjdGl2ZXMgYW5kIHBsdWdpbnM6IHdoZW4gYSBjb21wb25lbnQgaXMgbm90IHRoZSBhbnN3ZXIgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~Q3VzdG9tIGRpcmVjdGl2ZXMgYW5kIHBsdWdpbnM6IHdoZW4gYSBjb21wb25lbnQgaXMgbm90IHRoZSBhbnN3ZXI%2Cdescription_~V3JpdGUgdi1mb2N1cyBhbmQgdi1jbGljay1vdXRzaWRlIHdpdGggdHlwZWQgaG9va3MsIHJlZ2lzdGVyIHRoZW0gbG9jYWxseSBhbmQgZ2xvYmFsbHksIHNoaXAgdGhlbSBpbiBhIHBsdWdpbiwgYW5kIGxlYXJuIHdoZW4gYSBjb21wb3NhYmxlIGlzIHRoZSBiZXR0ZXIgdG9vbC4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jdXN0b20tZGlyZWN0aXZlcy1hbmQtcGx1Z2lucyI%2Cs_y2qsFbDJ_Rp24jCT.png","@type":["Article","BlogPosting"],"articleSection":["Deep dive"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/custom-directives-and-plugins#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/custom-directives-and-plugins#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/custom-directives-and-plugins#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Custom directives and plugins: when a component is not the answer","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~Q3VzdG9tIGRpcmVjdGl2ZXMgYW5kIHBsdWdpbnM6IHdoZW4gYSBjb21wb25lbnQgaXMgbm90IHRoZSBhbnN3ZXIgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~Q3VzdG9tIGRpcmVjdGl2ZXMgYW5kIHBsdWdpbnM6IHdoZW4gYSBjb21wb25lbnQgaXMgbm90IHRoZSBhbnN3ZXI%2Cdescription_~V3JpdGUgdi1mb2N1cyBhbmQgdi1jbGljay1vdXRzaWRlIHdpdGggdHlwZWQgaG9va3MsIHJlZ2lzdGVyIHRoZW0gbG9jYWxseSBhbmQgZ2xvYmFsbHksIHNoaXAgdGhlbSBpbiBhIHBsdWdpbiwgYW5kIGxlYXJuIHdoZW4gYSBjb21wb3NhYmxlIGlzIHRoZSBiZXR0ZXIgdG9vbC4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jdXN0b20tZGlyZWN0aXZlcy1hbmQtcGx1Z2lucyI%2Cs_y2qsFbDJ_Rp24jCT.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~Q3VzdG9tIGRpcmVjdGl2ZXMgYW5kIHBsdWdpbnM6IHdoZW4gYSBjb21wb25lbnQgaXMgbm90IHRoZSBhbnN3ZXIgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~Q3VzdG9tIGRpcmVjdGl2ZXMgYW5kIHBsdWdpbnM6IHdoZW4gYSBjb21wb25lbnQgaXMgbm90IHRoZSBhbnN3ZXI%2Cdescription_~V3JpdGUgdi1mb2N1cyBhbmQgdi1jbGljay1vdXRzaWRlIHdpdGggdHlwZWQgaG9va3MsIHJlZ2lzdGVyIHRoZW0gbG9jYWxseSBhbmQgZ2xvYmFsbHksIHNoaXAgdGhlbSBpbiBhIHBsdWdpbiwgYW5kIGxlYXJuIHdoZW4gYSBjb21wb3NhYmxlIGlzIHRoZSBiZXR0ZXIgdG9vbC4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jdXN0b20tZGlyZWN0aXZlcy1hbmQtcGx1Z2lucyI%2Cs_y2qsFbDJ_Rp24jCT.png"}]}
```