Vue 3 Directives: Built-in and Custom

Directives are special template attributes, always prefixed with v-, that connect your HTML to the state and behavior of your application. This article covers the built-in Vue 3 directives, the syntax with arguments and modifiers, how to choose between v-if and v-show, and how to build your own directive, including in Nuxt 4, where you also have to handle server-side rendering.
The examples use Vue 3.5 with <script setup>.
What Are Directives in Vue?
A directive takes a JavaScript expression as its value and applies reactive behavior to the element it sits on. When the value changes, Vue updates the element. Directives control attributes, events, list rendering and which elements appear on screen.
There are two groups. Built-in directives, like v-if and v-for, ship with Vue. Custom directives are the ones you write yourself for cases where you need to work directly with a DOM element.
How Does the Directive Syntax Work?
A directive has up to four parts: the name, an optional argument after a colon, optional modifiers separated by dots, and the value. The first two lines below are equivalent, because : is shorthand for v-bind: and @ is shorthand for v-on::
<template>
<a v-bind:href="url">Visit our website</a>
<a :href="url">Visit our website</a>
<button @click="save">Save</button>
<!-- dynamic argument: the attribute name comes from a variable -->
<a :[attribute]="url">Visit our website</a>
<!-- modifier: calls event.preventDefault() on submit -->
<form @submit.prevent="submit">...</form>
</template>
In Vue projects, the short form is the most common. Modifiers change how a directive behaves, and each directive has its own. .prevent and .stop apply to v-on, while .lazy, .number and .trim apply to v-model.
What Are the Main Built-in Directives?
v-bind: Dynamic Attributes
v-bind links the value of a variable to an element attribute. If the variable changes, the attribute changes with it:
<template>
<a :href="url">Visit our website</a>
</template>
<script setup>
import { ref } from "vue";
const url = ref("https://codigoaoponto.com/");
</script>
v-model: Two-Way Binding
v-model keeps a form field and a variable in sync in both directions. Typing in the field updates the variable, and changing the variable updates the field:
<template>
<input v-model="name" type="text" />
<div>Hello! My name is {{ name }}</div>
</template>
<script setup>
import { ref } from "vue";
const name = ref("Test");
</script>
On components, v-model relies on a matching prop and event. In Vue 3 you can have more than one v-model on the same component, each with its own name. Since Vue 3.4, the defineModel macro creates that prop and event pair for you.
v-on: Events
v-on connects a browser event to a function:
<template>
<input v-model="name" type="text" />
<button @click="reset">Reset</button>
</template>
<script setup>
import { ref } from "vue";
const name = ref("Test");
function reset() {
name.value = "";
}
</script>
v-for: Lists and the Key
v-for creates one element for each item in an array or object. It needs a :key with a unique, stable value for each item, so Vue knows which element matches which piece of data:
<template>
<ul>
<li v-for="task in tasks" :key="task.id">
{{ task.title }}
</li>
</ul>
</template>
<script setup>
const tasks = [
{ id: 101, title: "Review the documentation" },
{ id: 102, title: "Update the dependencies" },
{ id: 103, title: "Publish the article" }
];
</script>
The array index only works as a key in static lists. If the list can be reordered, filtered, or gain and lose items, the index makes Vue reuse the wrong element. The problem shows up mostly when each row has a text field, a component or an animation with its own state.
v-if, v-else-if and v-else: Conditional Rendering
v-if includes the element only when the condition is true. v-else-if and v-else chain the alternatives:
<template>
<input v-model="name" type="text" />
<div v-if="name.length > 10">Your name is too long!</div>
<div v-else-if="name">Hello! My name is {{ name }}</div>
<div v-else>Please enter your name.</div>
</template>
<script setup>
import { ref } from "vue";
const name = ref("");
</script>
v-show: Visibility
v-show keeps the element in the DOM and toggles the CSS display property:
<template>
<div v-show="name.length > 10">Your name is too long!</div>
</template>
v-html and v-text
v-text sets the element's text, like the {{ }} interpolation does. v-html inserts the string as HTML.
Never use
v-htmlwith user-provided or external content without sanitizing it first. Malicious HTML in that string is an XSS vulnerability. Regular interpolation treats everything as text and is the safe option.
Other Directives
Vue has five more directives that show up less often:
v-slotreceives the content of named and scoped slots.v-oncerenders the element once and skips later updates.v-preskips compiling the element and its children, which is useful for showing{{ }}literally.v-cloakhides the template until it is compiled, in projects without a build step.v-memoskips re-processing a part of the template while the listed values stay the same.
v-if or v-show: Which One Should You Choose?
Both hide an element, but in different ways. v-if only renders the block when the condition is true and, when it changes, destroys and recreates the block with its components and listeners. v-show always renders the element and only toggles display.
The documentation's guidance is to prefer v-show when you toggle something very often, because toggling is cheaper. Prefer v-if when the condition rarely changes at runtime, because the block is not even created until it is needed.
v-show has two limitations: it does not work on <template> and it cannot be combined with v-else. To hide several elements at once, use v-if on a <template>.
How to Create a Custom Directive in Vue 3
A custom directive is an object with lifecycle functions that receive the DOM element. The Vue documentation recommends preferring declarative templates and keeping directives for when you need direct access to the element.
A Local Directive with script setup
Inside <script setup>, any camelCase variable that starts with v becomes a directive. The variable vFocus is available in the template as v-focus:
<script setup>
const vFocus = {
mounted: (el) => el.focus()
};
</script>
<template>
<input v-focus />
</template>
Here the mounted hook runs when the element is inserted into the DOM and focuses the field.
Global Registration with app.directive
If several parts of the application use the directive, write it in its own file and register it once with app.directive:
// directives/focus.ts
import type { Directive } from "vue";
export const vFocus: Directive<HTMLElement> = {
mounted: (el) => el.focus()
};
// main.ts
import { createApp } from "vue";
import App from "./App.vue";
import { vFocus } from "./directives/focus";
const app = createApp(App);
app.directive("focus", vFocus);
app.mount("#app");
Lifecycle Hooks
A directive can implement these hooks, listed in the order they run:
created: before the element's attributes and listeners are applied.beforeMount: before the element is inserted into the DOM.mounted: after the parent component and all its children are mounted.beforeUpdate: before the parent component updates.updated: after the parent component and its children update.beforeUnmount: before the parent component is unmounted.unmounted: after the parent component is unmounted.
All of them receive el (the element), binding and vnode. The update hooks also receive prevVnode. The binding object carries the value passed to the directive, the oldValue (only in beforeUpdate and updated), the arg, the modifiers, the component instance and the directive definition itself in dir. Treat all of these except el as read-only.
Value, Argument and Modifiers
When the same behavior should run on mount and on every update, write the directive as a function. It is called on mounted and updated:
// directives/highlight.ts
import type { Directive } from "vue";
// usage: <p v-highlight:background.smooth="'#fff3b0'">Text</p>
export const vHighlight: Directive<HTMLElement, string> = (el, binding) => {
el.style.setProperty(binding.arg ?? "color", binding.value);
if (binding.modifiers.smooth) {
el.style.transition = "all 0.3s";
}
};
The arg is background, the smooth modifier shows up in binding.modifiers and the value is the color. When the directive needs several options, the value can be an object: v-highlight="{ color: 'white', duration: 300 }".
Cleaning Up Listeners
Any directive that registers a listener on document or window, an observer or a timer has to undo it when the element leaves the screen. The classic example is closing a menu when the user clicks outside of it:
// directives/clickOutside.ts
import type { Directive } from "vue";
type ElementWithListener = HTMLElement & {
_onClickOutside?: (event: MouseEvent) => void;
};
export const vClickOutside: Directive<ElementWithListener, () => void> = {
mounted(el, binding) {
el._onClickOutside = (event) => {
if (!el.contains(event.target as Node)) {
binding.value();
}
};
document.addEventListener("click", el._onClickOutside);
},
unmounted(el) {
if (el._onClickOutside) {
document.removeEventListener("click", el._onClickOutside);
delete el._onClickOutside;
}
}
};
The function is stored on the element so unmounted removes exactly the same reference that mounted registered. Without that, the listener stays alive after the element is gone. This example reads binding.value only on mount. If the function you pass can change, update the reference in the updated hook too.
Directive, Composable or Component?
A directive fits when the logic has to touch one specific element, like focusing a field. If the logic has state or works without being tied to an element, like a reusable debounce, a composable is a better fit. When there is visual structure, like an accessible tooltip or a menu with keyboard support and submenus, a component is the right choice.
A good tooltip needs positioning, focus, keyboard support and the Esc key. A directive that tries to cover all of that tends to end up more complicated than the equivalent component.
How to Use Custom Directives in Nuxt 4
In Nuxt, a global directive is registered in a plugin inside app/plugins/, using nuxtApp.vueApp.directive. Because Nuxt renders the page on the server, there is one more rule: during SSR, Vue ignores a directive's hooks, since they manipulate the DOM and the server has no DOM. If the directive needs to add an attribute to the generated HTML, use the getSSRProps hook, which returns the props to render:
// app/plugins/directives.ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive("focus", {
mounted: (el: HTMLElement) => el.focus(),
getSSRProps: () => ({ autofocus: true })
});
});
With this plugin, <input v-focus /> leaves the server as <input autofocus>. Without getSSRProps, it would be a bare <input>, and focus would only arrive after hydration.
The Nuxt documentation asks you to register the directive on both client and server, unless it is only used on one side. For a browser-only directive, like v-click-outside, create the plugin with a .client suffix and a .server plugin with an empty directive, only so Vue recognizes the name:
// app/plugins/click-outside.client.ts
import { vClickOutside } from "~/directives/clickOutside";
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive("click-outside", vClickOutside);
});
// app/plugins/click-outside.server.ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive("click-outside", {});
});
What Changed in Directives from Vue 2 to Vue 3?
Many older tutorials still use the Vue 2 hooks, and they do not work in Vue 3. Here is the mapping:
bindbecamebeforeMount.insertedbecamemounted.componentUpdatedbecameupdated.unbindbecameunmounted.updatewas removed, because it was redundant withupdated.created,beforeUpdateandbeforeUnmountare new.
The binding object also lost the expression string, and the component instance moved from vnode.context to binding.instance.
What Are the Most Common Mistakes with Directives?
- Using
v-ifandv-foron the same element.v-ifis evaluated first, and the documentation does not recommend the combination. Filter the list in a computed property or split it into two elements. - Using the index as
keyin lists that change. - Applying
v-htmlto text that came from a user. - Registering a listener or timer without removing it in
unmounted. - Applying a custom directive to a component with more than one root element. The directive is ignored and Vue emits a warning. The documentation does not recommend custom directives on components.
- Counting on DOM manipulation during SSR.
- Creating a directive for something that would be simpler as a component.
Frequently Asked Questions
What are directives in Vue?
They are special template attributes, prefixed with v-, that apply reactive behavior to an element, such as changing attributes, listening to events, repeating elements or controlling rendering.
What is the difference between v-if and v-show?
v-if mounts and unmounts the block depending on the condition. v-show keeps the element in the DOM and toggles display. Use v-show for frequent toggles and v-if when the condition rarely changes.
Can I use the array index as the key in v-for?
Only in static lists that never change order or size. In lists that change, use a stable id from the item itself.
How do I create a custom directive with script setup?
Declare a camelCase variable that starts with v, like vFocus, and use it in the template as v-focus. Vue treats that as local registration.
When should I use a directive instead of a composable?
Use a directive when the logic has to manipulate a DOM element directly. If the logic has state or does not depend on a specific element, prefer a composable.
Do custom directives work with SSR in Nuxt?
The hooks that touch the DOM do not run on the server. To generate attributes in the server HTML, implement getSSRProps. For a directive that only makes sense in the browser, register it in a .client plugin and create a .server stub.
Conclusion
To pick the right tool, start with the element: if the logic has to touch it, use a directive; if it has state and can be reused, use a composable; if it has visual structure, use a component. Among the built-in directives, swap the index for a stable id in v-for and choose between v-if and v-show by how often the toggle happens.
To go deeper, read the documentation pages on custom directives, conditional rendering, list rendering and Nuxt plugins.

