Vue.js: Custom Directives
Last updated: 2026-10-20
Custom directives let you extend Vue's template syntax — special attributes that start with v- and manipulate the underlying DOM directly. Vue ships with built-in directives such as v-if / v-for / v-model, and you can create your own v-focus / v-permission / v-debounce and more.
Custom directives are a powerful way to write "low-level DOM tools" — they package reusable DOM operations into declarative syntax. Understanding the five lifecycle hooks is what lets you write any kind of v-directive.
1. What You'll Learn
- The essence of custom directives and their 3 main use cases
- Global directives with
app.directive() - Local directives with
directives: {} - The 5 lifecycle hooks (created/beforeMount/mounted/beforeUpdate/unmounted)
- The 5 practical directives (v-focus / v-permission / v-debounce / v-copy / v-lazy-load)
- Directive arguments, modifiers, and values
- 5 anti-patterns (abuse, overriding built-ins, forgetting cleanup, etc.)
2. The Nightmare of a Permission Button "Repeated in 5 Places"
(1) Pain Point: All 5 Buttons Need a Permission Check
Alice's admin dashboard has 5 buttons that all need a permission check:
5 buttons × 5 permission checks = 25 lines of duplicated code. Add another button and you write another v-if.
Product manager Charlie:
"Alice, the dashboard has 50+ buttons. We need a 'v-permission' directive to make the code cleaner."
(2) Vue Custom Directive Solution: One v-permission
// directives/permission.js
export const permission = {
mounted(el, binding) {
const { value } = binding // 'user.create'
const userPermissions = getCurrentUser().permissions || []
if (!userPermissions.includes(value)) {
el.parentNode?.removeChild(el) // remove it when there is no permission
}
}
}
// main.js
import { permission } from './directives/permission'
app.directive('permission', permission)
50 buttons, 50 v-permission attributes — 50% more concise than 50 v-if blocks.
(3) Benefits
After switching to custom directives:
- Lines of code: 25 lines of v-if → 3 lines of v-permission (-88%)
- New buttons: one v-permission instead of one v-if
- Centralized permission logic: a single directives/permission.js
- Reusable: other projects can use v-permission too
3. Custom Directive Basics
(1) 3 Ways to Register
// 2. Shorthand (mounted + updated)
app.directive('color', (el, binding) => {
el.style.color = binding.value
})
(2) The 5 Lifecycle Hooks
app.directive('demo', {
// 1. created (directive created)
created(el, binding) {
console.log('1. directive created')
},
// 2. beforeMount (before the element is mounted)
beforeMount(el) {
console.log('2. before mount')
},
// 3. mounted (element mounted) ⭐ most used
mounted(el, binding) {
console.log('3. mounted')
},
// 4. beforeUpdate (before a dependency changes)
beforeUpdate(el, binding) {
console.log('4. before update')
},
// 5. updated (after a dependency changed)
updated(el, binding) {
console.log('5. updated')
},
// 6. beforeUnmount (before unmounting)
beforeUnmount(el) {
console.log('6. before unmount')
},
// 7. unmounted (after unmounting) ⭐ use it for cleanup
unmounted(el) {
console.log('7. unmounted')
}
})
(3) The Hook Arguments in Detail
// 4 arguments: el, binding, vnode, prevVnode
mounted(el, binding, vnode, prevVnode) {
// el: the element the directive is bound to
el.style.color = 'red'
// binding: the directive information object
binding.value // directive value, e.g. v-foo="bar" → bar
binding.arg // argument, e.g. v-foo:arg → 'arg'
binding.modifiers // modifiers, e.g. v-foo.bar → { bar: true }
binding.instance // the component instance using the directive
binding.dir // the directive definition object
// vnode: Vue virtual node (rarely needed)
// prevVnode: the previous virtual node
}
▶ Example: Register v-highlight with app.directive (value + modifiers)
▶ Try it YourselfOutput:
Three swatch buttons on top ("#ffe08a / #a5d8ff / #b2f2bb"); both lines below start with a pale-yellow background, the second one bold.
Click "#a5d8ff" → both backgrounds turn pale blue; the first line stays at normal weight while the second stays bold — the .bold modifier only affects the element it is attached to.
Click "#b2f2bb" → both lines turn pale green.
▶ Example: Learning from the 5 Built-in Directives (app.directive)
const { createApp } = Vue
const app = createApp({})
// 1. v-show (controls display)
app.directive('show', {
mounted(el, binding) { el.style.display = binding.value ? '' : 'none' },
updated(el, binding) { el.style.display = binding.value ? '' : 'none' }
})
// 2. v-text (sets textContent)
app.directive('text', {
mounted(el, binding) { el.textContent = binding.value },
updated(el, binding) { el.textContent = binding.value }
})
// 3. v-html (sets innerHTML, XSS risk)
app.directive('html', {
mounted(el, binding) { el.innerHTML = binding.value },
updated(el, binding) { el.innerHTML = binding.value }
})
Output:
Re-implements three of Vue's built-in directives by hand.
Rendering v-show="false" hides the element with display: none, v-text replaces the element's text, and v-html injects raw markup — which is exactly why v-html must never receive unsanitized user input.
v-show="false" -> <div style="display: none">
v-text="'Hi'" -> <div>Hi</div>
v-html="'<b>x</b>'" -> <div><b>x</b></div> (raw HTML is injected, XSS risk)
4. The 5 Practical Directives
(1) v-focus: Auto Focus
// directives/focus.js
export const focus = {
mounted(el, binding) {
if (binding.value !== false) {
el.focus()
}
}
}
(2) v-permission: Permission Control
// directives/permission.js
import { getCurrentUser } from '@/utils/auth'
export const permission = {
mounted(el, binding) {
const { value, modifiers } = binding
const user = getCurrentUser()
// value: the string 'user.create' or the array ['user.create', 'user.delete']
// modifiers.disable: disable instead of removing
const required = Array.isArray(value) ? value : [value]
const hasPermission = required.every(p =>
user.permissions?.includes(p)
)
if (!hasPermission) {
if (modifiers.disable) {
el.disabled = true
el.title = 'No permission'
} else {
el.parentNode?.removeChild(el)
}
}
}
}
(3) v-debounce: Debounced Events
// directives/debounce.js
export const debounce = {
mounted(el, binding) {
const { value, arg = 300 } = binding
if (typeof value !== 'function') {
console.warn('v-debounce: value must be a function')
return
}
let timer = null
el.__debounceTimer__ = timer
el.addEventListener('click', () => {
clearTimeout(timer)
timer = setTimeout(() => value(), arg)
el.__debounceTimer__ = timer
})
},
unmounted(el) {
if (el.__debounceTimer__) {
clearTimeout(el.__debounceTimer__)
}
}
}
(4) v-copy: Click to Copy
// directives/copy.js
export const copy = {
mounted(el, binding) {
const handler = async () => {
try {
await navigator.clipboard.writeText(binding.value)
const original = el.textContent
el.textContent = 'Copied!'
setTimeout(() => { el.textContent = original }, 1500)
} catch (err) {
console.error('Copy failed:', err)
}
}
el.addEventListener('click', handler)
el.__copyHandler__ = handler
},
unmounted(el) {
if (el.__copyHandler__) {
el.removeEventListener('click', el.__copyHandler__)
}
}
}
(5) v-lazy-load: Lazy Image Loading
// directives/lazyLoad.js
export const lazyLoad = {
mounted(el, binding) {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
el.src = binding.value
observer.unobserve(el)
}
})
observer.observe(el)
el.__observer__ = observer
},
unmounted(el) {
el.__observer__?.disconnect()
}
}
5. Directive Arguments, Modifiers, and Values
(1) 3 Directive Forms
<!-- 1. v-directive="value" (value) -->
<input v-foo="username">
<!-- 2. v-directive:arg (argument, a fixed string) -->
<input v-foo:delay="500">
<!-- 3. v-directive.modifier (modifier, a boolean object) -->
<input v-foo.bar>
<!-- 4. Combined -->
<input v-foo:delay.bar="500">
(2) How JavaScript Receives Them
app.directive('demo', (el, binding) => {
// v-demo="123"
binding.value // 123
// v-demo:abc
binding.arg // 'abc'
// v-demo.foo
binding.modifiers // { foo: true }
// v-demo:abc.foo="123"
binding.value // 123
binding.arg // 'abc'
binding.modifiers // { foo: true }
})
(3) 5 Common Combinations
<!-- Scenario 1: v-permission:disable -->
<button v-permission:disable="'user.create'">
<!-- arg='disable', value='user.create' -->
</button>
<!-- Scenario 2: v-debounce:500 -->
<button v-debounce:500="handler">
<!-- arg='500' (a 500ms debounce) -->
</button>
<!-- Scenario 3: v-once.lazy -->
<img v-once.lazy="imageUrl">
<!-- modifiers.lazy=true, value=imageUrl -->
</template>
6. Putting It Together: Three Directives Working Together
▶ Example: v-focus + v-copy + v-debounce Combined Demo
▶ Expand runnable example (80 lines)
Output:
Three demo blocks render at once.
The first input receives focus as soon as the page opens, the second one 500 ms later.
The Copy link button swaps its label to Copied! for 1.5 seconds after every click.
The save button runs handleSave only once per 800 ms burst, so clicking it three times quickly increments the counter by exactly 1.
v-focus (auto focus) -> input 1 focused on load, input 2 focused after 500 ms
v-copy (click to copy) -> button label: Copy link -> Copied! -> Copy link (1.5 s later)
v-debounce:800 (debounce) -> 3 rapid clicks => Actual trigger count: 1
(1) 📊 5 Common Mistakes at a Glance
| Mistake | Symptom | Fix |
|---|---|---|
| Directive name without v- | No effect | v-focus (not focus) |
| value is not a function | Never runs | Check v-debounce="handler" |
| Forgetting cleanup in unmounted | Memory leak | Clear timers and observers |
| Overriding a Vue built-in directive | Error | Do not name it v-if etc. |
| Value changes do not react | Only runs once on mounted | Use the updated hook |
Output:
A five-row troubleshooting table that maps each failure mode to its symptom and its fix, so you can diagnose most directive bugs by matching what you see on screen.
Renders one table: 5 rows x 3 columns (Mistake | Symptom | Fix).
No interactive elements, no console output.
(2) 📊 5-Way Comparison
| Approach | Reusability | Performance | Best For |
|---|---|---|---|
v-if + helper function |
❌ | ⭐⭐⭐ | One-off cases |
| Custom directive | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | DOM tools |
| Global component | ⭐⭐⭐ | ⭐⭐⭐ | Complex UI |
| Composable | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | Business logic |
| Pinia | ⭐⭐⭐⭐ | ⭐⭐⭐ | Global state |
Output:
A single five-row comparison table.
Reading down the "Best For" column tells you which tool fits a given need;
the star ratings are qualitative, not measured benchmarks.
Renders one table: 5 rows x 4 columns (Approach | Reusability | Performance | Best For).
❓ FAQ
watch(binding.value) or compare old and new values inside the updated hook.binding.modifiers.disable to decide whether to disable or remove the element.defineDirective plus TypeScript generics. Usually, though, a plain JS object is simpler.<input v-my-directive="myRef" />, binding.value is the ref object. It is not recommended, though — that is an anti-pattern.vFocus / vClickOutside / vLazyLoad / vInfiniteScroll are all directives provided by VueUse. Prefer VueUse over writing your own.📖 Summary
- A custom directive is a special
v-prefixed attribute used to manipulate the DOM directly - 3 ways to register: global (
app.directive) / local (directives: {}) / shorthand (mounted + updated merged) - The 5 lifecycle hooks: created / beforeMount / mounted / beforeUpdate / updated / beforeUnmount / unmounted
- The 5 practical directives: v-focus / v-permission / v-debounce / v-copy / v-lazy-load
- 3 forms: value / argument (arg) / modifier (modifiers)
- 5 anti-patterns: forgetting the v- prefix / value not a function / forgetting cleanup / overriding built-ins / not reacting to value changes
📝 Exercises
-
Basic (difficulty ⭐) Implement a v-focus directive:
- Accept an arg (the delay in milliseconds before focusing)
- Accept a modifier (prevent: block the default behavior)
-
Intermediate (difficulty ⭐⭐) Implement the full v-permission:
- Support a single permission string: v-permission="'user.create'"
- Support an array of permissions: v-permission="['user.read', 'user.write']"
- Support the modifier: .disable (disable instead of remove)
- Work with currentUser (injected via provide/inject)
-
Challenge (difficulty ⭐⭐⭐) Build a complete "directive library":
- 5 directives: v-focus / v-permission / v-debounce / v-copy / v-lazy-load
- A complete implementation plus TypeScript types for each one
- Export everything from directives/index.js
- 5 test cases (with Vitest)
- Use it in real scenarios (5 different screens of an e-commerce admin)
- Document the API and parameters of every directive