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.value may 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.

Directive Hooks and the Shared ObserverFour boxes. In mounted, the element is registered with the shared observer for its option set and its handler is stored in a WeakMap. In updated, only the stored handler is replaced; nothing is re-observed. When an entry arrives, the handler for that element is called, and with the once modifier the element is released. In beforeUnmount, the element is unobserved and its handler removed.mountedpool(options).observe(el)updatedswap handler onlyEntrycall handler; once →releasebeforeUnmountunobserve, drop handler

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

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

TypeScript
// 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
};
VUE
<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.

Observers Created for a 60-Product GridA bar chart of observer instances created for a sixty-product grid with an impression and a lazy image per product. A per-component composable created one hundred and twenty observers. The directive with pooling by options created two: one for impressions at half visibility and one for images with a four hundred pixel margin.60 products, impression + lazy image eachcomposable per element120 observersv-intersect, pooled by options2 observers

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

TypeScript
// 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:

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

Directive or Composable?A decision chain. If visibility changes reactive state that the template renders, use the composable. Otherwise, if the behaviour applies to many elements in v-for, use the directive. Otherwise, if the observed element has a custom root that lives in the same component, the composable is simpler. Otherwise, for fire-and-forget behaviour such as analytics or lazy loading, use the directive.Does visibility drive rendered state?Composable: returns a reactive refyesnoMany elements in a v-for?Directive: per-element hooksyesnoCustom root in the same component?Composable: template ref for the rootyesnoFire-and-forget behaviour: directive.

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 IntersectionObserver on the server.
  • Type-check templates with vue-tsc to 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.


↑ Back to Vue Observer Composables