Building Reusable Custom Directives for Vue 3 Applications

Custom directives in Vue 3 provide a powerful mechanism for direct DOM manpiulation and reusable behavior encapsulation. When registered globally, these directives become available across all components without additional imports. The following implementations demonstrate common patterns for permissions, performence optimization, and UI enhancements.

Permission Control

This directive toggles element visibility based on access rights. It compares the required permission against a predefined list of user capabilities.

directives/permission.js

import { ref, watchEffect } from 'vue';

const checkAccess = (requiredRole) => {
    // Mock user roles retrieved from authentication service
    const currentRoles = ['admin', 'editor'];
    return currentRoles.includes(requiredRole);
};

export default {
    mounted(element, binding) {
        const requiredPermission = binding.value;
        const isVisible = ref(false);

        watchEffect(() => {
            isVisible.value = checkAccess(requiredPermission);
        });

        element.style.display = isVisible.value ? '' : 'none';
    }
};

components/AccessControl.vue

<template>
  <div>
    <button v-permission="'admin'">Admin Panel</button>
    <button v-permission="'editor'">Edit Content</button>
    <button v-permission="'guest'">Guest Area</button>
  </div>
</template>

<script setup>
// Component logic
</script>

Input Debouncing

Reduce the frequency of event execution, particularly useful for search inputs or API calls triggered by user typing.

directives/debounce.js

export default {
    mounted(element, binding) {
        const config = binding.value;
        if (typeof config.handler !== 'function' || !config.type) return;

        let timeoutId = null;

        const execute = function(...args) {
            if (timeoutId) clearTimeout(timeoutId);
            
            timeoutId = setTimeout(() => {
                config.handler.apply(this, args);
                timeoutId = null;
            }, config.wait || 400);
        };

        element.addEventListener(config.type, execute);
        element._debounceHandler = execute;
    },
    beforeUnmount(element, binding) {
        if (element._debounceHandler) {
            element.removeEventListener(binding.value.type, element._debounceHandler);
        }
    }
};

components/SearchInput.vue

<template>
  <input 
    v-model="query" 
    v-debounce="searchConfig" 
    placeholder="Type to search..." 
  />
</template>

<script setup>
import { ref } from 'vue';

const query = ref('');

const searchConfig = {
  type: 'input',
  wait: 600,
  handler: (event) => {
    console.log('Searching for:', event.target.value);
  }
};
</script>

Event Throttling

Unlike debouncing, throttling ensures a function executes at a steady interval. This is ideal for scroll events or window resizing.

directives/throttle.js

export default {
    mounted(element, binding) {
        const { handler, type, interval } = binding.value;
        if (typeof handler !== 'function' || !type) return;

        let lastExecution = 0;
        let timeoutId = null;

        const throttledFn = function(...args) {
            const now = Date.now();
            const remaining = interval - (now - lastExecution);

            if (remaining <= 0) {
                if (timeoutId) {
                    clearTimeout(timeoutId);
                    timeoutId = null;
                }
                lastExecution = now;
                handler.apply(this, args);
            } else if (!timeoutId) {
                timeoutId = setTimeout(() => {
                    lastExecution = Date.now();
                    handler.apply(this, args);
                    timeoutId = null;
                }, remaining);
            }
        };

        element.addEventListener(type, throttledFn);
        element._throttleHandler = throttledFn;
    },
    beforeUnmount(element, binding) {
        if (element._throttleHandler) {
            element.removeEventListener(binding.value.type, element._throttleHandler);
        }
    }
};

components/ThrottleButton.vue

<template>
  <button v-throttle="clickConfig">Submit</button>
</template>

<script setup>
const clickConfig = {
  type: 'click',
  interval: 2000,
  handler: () => {
    console.log('Action executed');
  }
};
</script>

Element Resize Observation

Monitor changes in an element's dimensions to trigger layout adjustments or data fetching.

directives/resize.js

export default {
    mounted(element, binding) {
        const callback = binding.value;
        
        const observer = new ResizeObserver((entries) => {
            for (let entry of entries) {
                const { width, height } = entry.contentRect;
                callback({ width, height });
            }
        });

        observer.observe(element);
        element._resizeObserver = observer;
    },
    beforeUnmount(element) {
        if (element._resizeObserver) {
            element._resizeObserver.disconnect();
        }
    }
};

components/ResizableBox.vue

<template>
  <div v-resize="onResize" style="width: 50%; height: 200px; background: #eee;">
    Resize this window
  </div>
</template>

<script setup>
const onResize = (size) => {
  console.log(`New dimensions: ${size.width}x${size.height}`);
};
</script>

Infinite Scroll Loading

Detect when a user scrolls near the bottom of a container to load additional data.

directives/load-more.js

export default {
    mounted(element, binding) {
        const { action, offset = 150 } = binding.value;

        const onScroll = () => {
            const { scrollTop, scrollHeight, clientHeight } = element;
            if (scrollHeight - scrollTop - clientHeight <= offset) {
                action();
            }
        };

        element.addEventListener('scroll', onScroll);
        element._scrollHandler = onScroll;
    },
    beforeUnmount(element) {
        if (element._scrollHandler) {
            element.removeEventListener('scroll', element._scrollHandler);
        }
    }
};

components/ListContainer.vue

<template>
  <div 
    v-load-more="scrollConfig"
    style="height: 300px; overflow-y: auto; border: 1px solid #ddd;"
  >
    <div v-for="n in 20" :key="n">Item {{ n }}</div>
  </div>
</template>

<script setup>
const scrollConfig = {
  offset: 100,
  action: () => {
    console.log('Loading more items...');
  }
};
</script>

Image Lazy Loading

Defer loading images until they enter the viewport to improve initial page load performance.

directives/lazy-img.js

export default {
    mounted(element, binding) {
        const observer = new IntersectionObserver((entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    const img = element;
                    img.src = binding.value;
                    observer.unobserve(img);
                }
            });
        }, { rootMargin: '50px' });

        observer.observe(element);
        element._lazyObserver = observer;
    },
    beforeUnmount(element) {
        if (element._lazyObserver) {
            element._lazyObserver.disconnect();
        }
    }
};

components/Gallery.vue

<template>
  <img 
    v-lazy-img="imageUrl" 
    alt="Content" 
    style="width: 100%; height: auto;" 
  />
</template>

<script setup>
const imageUrl = 'https://example.com/image.jpg';
</script>

Input Validation Mask

Restrict user input based on a regular expression pattern, such as allowing only numbers or specific characters.

directives/input-mask.js

export default {
    mounted(element, binding) {
        const pattern = binding.value;
        
        const onInput = (e) => {
            const value = e.target.value;
            const valid = pattern.test(value);
            
            if (!valid) {
                e.target.value = value.slice(0, -1);
            }
        };

        element.addEventListener('input', onInput);
        element._maskHandler = onInput;
    },
    beforeUnmount(element) {
        if (element._maskHandler) {
            element.removeEventListener('input', element._maskHandler);
        }
    }
};

components/NumberInput.vue

<template>
  <input 
    type="text" 
    v-input-mask="/^[0-9]*$/" 
    placeholder="Numbers only" 
  />
</template>

Draggable Elements

Enable any DOM element to be moved freely within its container using mouse events.

directives/drag.js

export default {
    mounted(element) {
        let startX = 0;
        let startY = 0;
        let initialLeft = 0;
        let initialTop = 0;

        const onMouseDown = (e) => {
            startX = e.clientX;
            startY = e.clientY;
            
            const rect = element.getBoundingClientRect();
            initialLeft = rect.left;
            initialTop = rect.top;

            document.addEventListener('mousemove', onMouseMove);
            document.addEventListener('mouseup', onMouseUp);
        };

        const onMouseMove = (e) => {
            const dx = e.clientX - startX;
            const dy = e.clientY - startY;
            
            element.style.left = `${initialLeft + dx}px`;
            element.style.top = `${initialTop + dy}px`;
        };

        const onMouseUp = () => {
            document.removeEventListener('mousemove', onMouseMove);
            document.removeEventListener('mouseup', onMouseUp);
        };

        element.style.position = 'absolute';
        element.style.cursor = 'move';
        element.addEventListener('mousedown', onMouseDown);
        element._dragHandler = onMouseDown;
    },
    beforeUnmount(element) {
        // Cleanup if necessary
    }
};

components/DraggableCard.vue

<template>
  <div v-drag style="width: 100px; height: 100px; background: blue;">
    Drag
  </div>
</template>

Dynamic Watermarking

Generate a background watermark using Canvas to protect content or indicate document status.

directives/watermark.js

const createWatermark = (text, container, color, fontSize) => {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    
    canvas.width = 300;
    canvas.height = 200;
    
    ctx.translate(canvas.width / 2, canvas.height / 2);
    ctx.rotate((-25 * Math.PI) / 180);
    ctx.font = `${fontSize || 14}px sans-serif`;
    ctx.fillStyle = color || 'rgba(0, 0, 0, 0.1)';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(text, 0, 0);
    
    container.style.backgroundImage = `url(${canvas.toDataURL()})`;
    container.style.backgroundRepeat = 'repeat';
};

export default {
    mounted(element, binding) {
        const { text, color, fontSize } = binding.value;
        createWatermark(text, element, color, fontSize);
    }
};

components/SecureDoc.vue

<template>
  <div 
    v-watermark="wmConfig"
    style="width: 100%; height: 400px; border: 1px solid #ccc;"
  >
    Confidential Content
  </div>
</template>

<script setup>
const wmConfig = {
  text: 'CONFIDENTIAL',
  color: 'rgba(200, 0, 0, 0.2)',
  fontSize: 20
};
</script>

Global registration in main.js ensures thece directives are accessible throughout the application lifecycle without individual component imports.

Tags: vue3 javascript custom-directives frontend web-development

Posted on Wed, 21 Oct 2026 16:19:17 +0000 by khan kaka