Custom directives: Vue & Nuxt best practices explained
Custom directives let you attach low-level DOM behavior to elements without building a full component wrapper. When you need to reuse focus management, click detection, or tooltip logic across your codebase, directives give you a lightweight, reusable way to hook into Vue's lifecycle. In this post, we'll walk through when to reach for directives, how to define them properly, and the common pitfalls we've seen teams hit in production.
When to use custom directives
Directives shine when you have DOM-level concerns that don't warrant a component. A component brings layout, styling, state management, and event emission. A directive wraps a single behavior—focus, validation feedback, event trapping, interaction patterns—and applies it to any element.
We've found three scenarios where custom directives pay off:
Focus management
A search input should grab focus on page load or modal open. Instead of writing a mounted hook in every parent component, write it once as a directive and reuse it:
const vFocus = {
mounted: (el: HTMLElement) => {
el.focus();
}
};Then in your template:
<input v-focus type="text" placeholder="Search..." />The element receives focus on mount. No component overhead, no prop drilling. In Nuxt, you can register this globally in a plugin so it's available everywhere.
Click-outside detection
Dropdowns, popovers, and modals often need to close when the user clicks outside them. A directive encapsulates this cleanly:
const vClickOutside = {
mounted(el: HTMLElement, binding: any) {
el.clickOutsideEvent = function(event: Event) {
if (!(el === event.target || el.contains(event.target as Node))) {
binding.value(event);
}
};
document.addEventListener('click', el.clickOutsideEvent);
},
unmounted(el: any) {
document.removeEventListener('click', el.clickOutsideEvent);
}
};Usage:
<div v-click-outside="handleClose" class="dropdown">
<button @click="isOpen = true">Menu</button>
<ul v-if="isOpen">...</ul>
</div>When a click fires outside the dropdown, your handler runs. No complex event bubbling logic in the component itself.
Tooltips and popovers
A tooltip directive can position and show a floating label on hover, applying consistent styling and behavior across many elements:
const vTooltip = {
mounted(el: HTMLElement, binding: any) {
const text = binding.value;
const tooltip = document.createElement('div');
tooltip.className = 'tooltip';
tooltip.textContent = text;
tooltip.style.display = 'none';
el.appendChild(tooltip);
el.addEventListener('mouseenter', () => {
tooltip.style.display = 'block';
});
el.addEventListener('mouseleave', () => {
tooltip.style.display = 'none';
});
}
};Then:
<button v-tooltip="'Delete this item'">Delete</button>Every button with that directive behaves the same. You maintain tooltip logic in one place.
Define directives with lifecycle hooks
A Vue directive is an object (or a shorthand function) with lifecycle hooks. The two most common are:
- mounted: fires after the element is inserted into the DOM
- unmounted: fires before the element is removed
Other hooks include:
- created: after the directive's binding object is created, before mounting
- beforeMount: before the element is added to the DOM
- updated: after the component containing the directive updates (e.g., binding value changes)
- beforeUpdate: before the component updates
In most cases, mounted and unmounted are enough. Here's a robust pattern for a directive that needs cleanup:
const vLazyLoad = {
mounted(el: HTMLElement, binding: any) {
const imageSrc = binding.value;
const handleIntersection = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
img.src = imageSrc;
observer.unobserve(img);
}
});
};
const observer = new IntersectionObserver(handleIntersection);
observer.observe(el);
// Store observer reference for cleanup
(el as any)._lazyObserver = observer;
},
unmounted(el: any) {
if (el._lazyObserver) {
el._lazyObserver.disconnect();
delete el._lazyObserver;
}
}
};The directive creates an IntersectionObserver, stores it on the element, and cleans up when the element unmounts. No memory leaks, no dangling listeners.
Register directives locally and globally
In script setup, you can register a directive locally by prefixing it with v:
<script setup lang="ts">
const vFocus = {
mounted: (el: HTMLElement) => el.focus()
};
</script>
<template>
<input v-focus type="text" />
</template>The v prefix tells Vue this is a directive. In the template, you use it as v-focus.
For reuse across multiple components, register globally in a Nuxt plugin:
// plugins/directives.ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive('focus', {
mounted: (el: HTMLElement) => el.focus()
});
nuxtApp.vueApp.directive('click-outside', {
mounted(el: HTMLElement, binding: any) {
el.clickOutsideEvent = function(event: Event) {
if (!(el === event.target || el.contains(event.target as Node))) {
binding.value(event);
}
};
document.addEventListener('click', el.clickOutsideEvent);
},
unmounted(el: any) {
document.removeEventListener('click', el.clickOutsideEvent);
}
});
});Now v-focus and v-click-outside are available in every component without importing. In Nuxt, the auto-discovery mechanism will pick up files in plugins/, so you can use these directives immediately.
For larger teams, create a composable or utility file to define directives, then import and register in the plugin:
// utils/directives.ts
export const vFocus = { mounted: (el: HTMLElement) => el.focus() };
export const vClickOutside = { /* ... */ };
// plugins/directives.ts
import { vFocus, vClickOutside } from '~/utils/directives';
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive('focus', vFocus);
nuxtApp.vueApp.directive('click-outside', vClickOutside);
});Prefer components for complex logic
A directive should handle DOM interaction, not business logic. If you're storing state, managing multiple props, emitting events for user actions, or coordinating several concerns—use a component instead.
For example, a tabs component with state, event handlers, and conditional rendering belongs in a component:
<script setup lang="ts">
import { ref } from 'vue';
const activeTab = ref(0);
const tabs = [
{ label: 'Tab 1', content: 'Content 1' },
{ label: 'Tab 2', content: 'Content 2' }
];
const selectTab = (index: number) => {
activeTab.value = index;
};
</script>
<template>
<div class="tabs">
<button
v-for="(tab, i) in tabs"
:key="i"
:class="{ active: activeTab === i }"
@click="selectTab(i)"
>
{{ tab.label }}
</button>
<div class="tab-content">
{{ tabs[activeTab].content }}
</div>
</div>
</template>But a directive that adds a keyboard shortcut handler? That's pure DOM behavior:
const vShortcut = {
mounted(el: HTMLElement, binding: any) {
const { key, handler } = binding.value;
el.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === key) {
event.preventDefault();
handler();
}
});
}
};The rule of thumb: if the element's behavior doesn't change (mounting, unmounting, accepting a value), it's probably a directive. If you need to track changes, manage internal state, or define complex interactions, build a component.
Common mistakes
Forgetting to clean up event listeners
A directive that adds listeners in mounted must remove them in unmounted. Otherwise, every time a component remounts, you add another listener and never remove it:
// Bad
const vBadListener = {
mounted(el: HTMLElement) {
el.addEventListener('click', () => console.log('clicked'));
}
// No unmounted hook—listener stays in memory
};
// Good
const vGoodListener = {
mounted(el: HTMLElement) {
const handler = () => console.log('clicked');
el.addEventListener('click', handler);
(el as any)._clickHandler = handler;
},
unmounted(el: any) {
if (el._clickHandler) {
el.removeEventListener('click', el._clickHandler);
delete el._clickHandler;
}
}
};Modifying component state from a directive
Directives shouldn't directly mutate parent component state. Instead, emit events or use the binding value. This keeps your data flow predictable and testable:
// Bad: directive modifying component ref
const vBadState = {
mounted(el: HTMLElement, binding: any) {
binding.instance.isOpen = true; // Don't do this
}
};
// Good: pass a callback to the directive
const vGoodState = {
mounted(el: HTMLElement, binding: any) {
el.addEventListener('click', () => {
binding.value(); // Call the provided function
});
}
};Using directives for styling
Directives should not apply CSS classes or styles based on component state. That's a job for computed properties, conditional classes, or components. Directives handle behavior, not presentation:
// Bad
const vHighlight = {
mounted(el: HTMLElement, binding: any) {
if (binding.value) {
el.style.backgroundColor = 'yellow'; // Don't style in directives
}
}
};
// Good: use conditional classes
<div :class="{ highlight: isImportant }">...</div>Passing complex objects as directive values
Keep directive arguments simple. If you're tempted to pass an object with five properties, consider building a component instead. A directive with a simple string or function is easier to read and maintain:
// Overly complex
<div v-tooltip="{
text: 'Help',
position: 'top',
delay: 200,
theme: 'dark',
maxWidth: 300
}">...</div>
// Better: use a component
<Tooltip text="Help" position="top" delay="200" theme="dark" max-width="300">
<div>...</div>
</Tooltip>Key takeaways
- Custom directives encapsulate reusable low-level DOM behavior—focus, click detection, animations—without component overhead.
- Define directives as objects with lifecycle hooks like mounted and unmounted; register locally in script setup or globally via a Nuxt plugin.
- Use directives only for DOM concerns. Complex state, props, and event logic belong in components.
- Always clean up listeners and observers in unmounted to prevent memory leaks.
- Keep directive values simple (strings, functions) to maintain readability and testability.
- Directives handle behavior, not styling or layout—let CSS classes and components manage presentation.






Comments
Loading comments…