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.

Related Directives:app.directive() v-model v-for v-if

1. What You'll Learn


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

JS
// 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
    }
  }
}
JS
// 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:


3. Custom Directive Basics

(1) 3 Ways to Register

JS
// 2. Shorthand (mounted + updated)
app.directive('color', (el, binding) => {
  el.style.color = binding.value
})

(2) The 5 Lifecycle Hooks

JS
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

JS
// 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 Yourself

Output:

TEXT
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)

JS
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 }
})
▶ Try it Yourself

Output:

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

JS
// directives/focus.js
export const focus = {
  mounted(el, binding) {
    if (binding.value !== false) {
      el.focus()
    }
  }
}

(2) v-permission: Permission Control

JS
// 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

JS
// 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

JS
// 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

JS
// 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

VUE
<!-- 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

JS
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

VUE
<!-- 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)
▶ Try it Yourself

Output:

TEXT
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.
TEXT
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:

TEXT
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.
TEXT
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:

TEXT
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.
TEXT
Renders one table: 5 rows x 4 columns (Approach | Reusability | Performance | Best For).

Related Directives:app.directive() v-model v-for v-if

❓ FAQ

Q How do I choose between a custom directive and a component?
A Use a directive for low-level DOM work (focus/scroll/canvas). Use a component for complex UI (with state/events/data). Directives are stateless; components are stateful.
Q Is a directive's value reactive?
A When the value changes, the updated hook fires. If you need to react to value changes, use watch(binding.value) or compare old and new values inside the updated hook.
Q How do I write v-permission:disable?
A arg='disable', modifiers.disable=true. Inside the directive, check binding.modifiers.disable to decide whether to disable or remove the element.
Q Can directives use TypeScript?
A Yes. Vue 3.3+ supports defineDirective plus TypeScript generics. Usually, though, a plain JS object is simpler.
Q Global or local directives — which should I pick?
A Domain-specific ones (v-permission) stay local. General-purpose tools (v-focus / v-copy) can be global. This tutorial recommends local, because it is easier to maintain.
Q Can a directive receive a ref?
A Yes. With <input v-my-directive="myRef" />, binding.value is the ref object. It is not recommended, though — that is an anti-pattern.
Q Does the VueUse library provide directives?
A Yes. vFocus / vClickOutside / vLazyLoad / vInfiniteScroll are all directives provided by VueUse. Prefer VueUse over writing your own.

📖 Summary


📝 Exercises

  1. Basic (difficulty ⭐) Implement a v-focus directive:

    • Accept an arg (the delay in milliseconds before focusing)
    • Accept a modifier (prevent: block the default behavior)
  2. 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)
  3. Challenge (difficulty ⭐⭐⭐) Build a complete "directive library":

    1. 5 directives: v-focus / v-permission / v-debounce / v-copy / v-lazy-load
    2. A complete implementation plus TypeScript types for each one
    3. Export everything from directives/index.js
    4. 5 test cases (with Vitest)
    5. Use it in real scenarios (5 different screens of an e-commerce admin)
    6. Document the API and parameters of every directive
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏