A Vue v-intersect directive registers its element with a shared IntersectionObserver in mounted, updates the stored handler in updated without re-observing, unregisters in beforeUnmount, and exposes options through the binding's argument, modifiers and value — so templates can write v-intersect.once="onVisible" on any element, including inside v-for.
Problem / Scenario Context
A Vue 3 e-commerce app needs visibility behaviour in many templates: lazy images, analytics impressions on product tiles, reveal animations, and "load more" sentinels. The team's useIntersectionObserver composable works well inside <script setup>, but using it for every tile in a v-for means creating template refs arrays, watching them, and mapping entries back to items — verbose and easy to get wrong. Designers editing templates want something declarative.
Custom directives are Vue's element-level hook. The Vue Observer Composables topic covers composables; this page covers the directive, and when each fits.
Mechanics Explanation
A Vue 3 custom directive is an object with lifecycle hooks called for the element it is bound to:
mounted(el, binding)— the element is inserted; safe to observe.updated(el, binding)— the component re-rendered;binding.valuemay be a new function.beforeUnmount(el)— the element is about to be removed; unobserve here.getSSRProps(binding)— called during server rendering; return extra attributes, or nothing.
The binding carries value (the expression: usually a handler), arg (v-intersect:200px → '200px'), and modifiers (v-intersect.once.half → { once: true, half: true }).
Because hooks run per element, a directive in a v-for naturally handles each item: the element is known, the handler can close over the item, and removal calls beforeUnmount. A shared observer per option set keeps it cheap, following shared observer pooling.
binding.value can change on every parent re-render if it is an inline arrow function. Re-observing on every update would be wasteful and would produce a fresh initial entry each time; storing the latest handler in a WeakMap keyed by element avoids both.
Comparison Table: Directive vs Composable
| Aspect | v-intersect directive |
useIntersectionObserver composable |
|---|---|---|
| Usage | template attribute | <script setup> code |
Inside v-for |
natural, per element | needs ref arrays and mapping |
| Returns reactive state | no — calls a handler | yes — isVisible ref |
| Custom root element | via binding value object | natural (template ref) |
| SSR | getSSRProps, no-op |
onMounted guard |
| Best for | fire-and-forget behaviour on many elements | state that drives rendering |
Minimal Reproducible Example
<script setup lang="ts">
import { ref, watch } from 'vue';
const tiles = ref<HTMLElement[]>([]);
const io = new IntersectionObserver((es) => es.forEach((e) => e.isIntersecting && track(e.target)));
watch(tiles, (els) => els.forEach((el) => io.observe(el)), { deep: true }); // never unobserves removed tiles
declare function track(el: Element): void;
</script>
<template>
<div v-for="p in products" :key="p.id" ref="tiles">{{ p.name }}</div>
</template>
Removed tiles remain observed, the observer is created per component instance, and mapping entries back to products requires data attributes.
Production-Safe Solution
// directives/intersect.ts
import type { Directive, DirectiveBinding } from 'vue';
type Handler = (entry: IntersectionObserverEntry) => void;
interface Config { handler: Handler; root?: Element | null }
type Value = Handler | Config;
const pools = new Map<string, IntersectionObserver>();
const handlers = new WeakMap<Element, { handler: Handler; once: boolean; key: string }>();
function optionsFrom(b: DirectiveBinding<Value>): { key: string; init: IntersectionObserverInit } {
const rootMargin = b.arg ?? '0px';
const threshold = b.modifiers.half ? 0.5 : b.modifiers.full ? 1 : 0;
const root = typeof b.value === 'function' ? null : b.value.root ?? null;
// Custom roots are not shareable across components by key; give them their own observer.
const key = root ? `root:${Math.random()}` : `${rootMargin}|${threshold}`;
return { key, init: { root, rootMargin, threshold } };
}
function pool(key: string, init: IntersectionObserverInit): IntersectionObserver {
let io = pools.get(key);
if (!io) {
io = new IntersectionObserver((entries) => {
for (const e of entries) {
const h = handlers.get(e.target);
if (!h) continue;
h.handler(e);
if (h.once && e.isIntersecting) release(e.target);
}
}, init);
pools.set(key, io);
}
return io;
}
function release(el: Element): void {
const h = handlers.get(el);
if (!h) return;
pools.get(h.key)?.unobserve(el);
handlers.delete(el);
}
const handlerOf = (v: Value): Handler => (typeof v === 'function' ? v : v.handler);
export const vIntersect: Directive<HTMLElement, Value> = {
mounted(el, binding) {
const { key, init } = optionsFrom(binding);
handlers.set(el, { handler: handlerOf(binding.value), once: !!binding.modifiers.once, key });
pool(key, init).observe(el);
},
updated(el, binding) {
const h = handlers.get(el);
if (h) h.handler = handlerOf(binding.value); // no re-observe
},
beforeUnmount(el) { release(el); },
getSSRProps() { return {}; }, // nothing to do on the server
};
<script setup lang="ts">
import { vIntersect } from '@/directives/intersect';
defineProps<{ products: { id: string; name: string; image: string }[] }>();
function impression(id: string) { return (e: IntersectionObserverEntry) => e.isIntersecting && track(id); }
declare function track(id: string): void;
</script>
<template>
<article v-for="p in products" :key="p.id" v-intersect.once.half="impression(p.id)">
<img v-intersect:400px.once="(e) => e.isIntersecting && ((e.target as HTMLImageElement).src = p.image)"
:alt="p.name" width="300" height="300">
<h3>{{ p.name }}</h3>
</article>
</template>
Each tile gets a half-visible, one-shot impression; each image gets a 400 px pre-load. All tiles share one observer and all images share another, keyed by their options. Inline handlers that change identity on re-render only update the stored handler. Removed items are unobserved in beforeUnmount.
Registering and Typing the Directive
Register globally in the app entry, or import locally in <script setup> (any variable named vSomething is available as v-something):
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { vIntersect } from './directives/intersect';
createApp(App).directive('intersect', vIntersect).mount('#app');
For template type checking of a globally registered directive, augment Vue's component custom properties:
declare module 'vue' {
interface ComponentCustomProperties { vIntersect: typeof import('./directives/intersect').vIntersect }
}
In Nuxt, register it in a plugin with nuxtApp.vueApp.directive('intersect', vIntersect); the getSSRProps hook keeps server rendering happy.
Verification Steps
- Heap snapshot: one observer per distinct option set, not per element.
- Filter the product list and confirm removed tiles are unobserved.
- Re-render the parent with new inline handlers and confirm no fresh initial entries (no re-observe).
- SSR build: the directive must not reference
IntersectionObserveron the server. - Type-check templates with
vue-tscto confirm the directive's value type.
Common Mistakes to Avoid
- Creating an observer in each
mounted. Pool by options. - Re-observing in
updated. It resets state and produces spurious initial entries. - Forgetting
beforeUnmount. Removed elements stay observed and retained. - Using a directive when the template needs a reactive value. Use a composable instead.
FAQ
What is the difference between beforeUnmount and unmounted for cleanup?
Both work for unobserving. beforeUnmount runs while the element is still in the DOM, which is useful if the handler wants to read final state; unmounted runs after removal. Either releases the observation.
How do modifiers become observer options?
The directive reads binding.modifiers and binding.arg in mounted and maps them to threshold and rootMargin. Options are fixed for the element's lifetime, matching the observer's immutable options.
Can a directive return a reactive visible value?
Not directly; directives have no return value. They can set an attribute or class on the element, or call a handler that updates reactive state in the component. If the component needs a ref, a composable is the better tool.
Is the directive safe with SSR?
Directive hooks like mounted do not run on the server. Implementing getSSRProps (even returning an empty object) is recommended so server rendering knows the directive intentionally adds nothing.
How do I use a scroll container as the root?
Pass an object value with a root element, as supported above. Because the root is specific to that container, the directive gives it its own observer rather than sharing by option key.
Does the directive work on components?
On a component, a directive applies to its root element. For components with multiple root nodes, Vue warns and ignores the directive; wrap them in an element.
Related
- Writing a useResizeObserver Composable for Vue 3 — the composable approach
- VueUse useIntersectionObserver vs a Hand-Rolled Composable — library or custom
- Writing a Svelte IntersectionObserver Action — the Svelte equivalent
↑ Back to Vue Observer Composables