Overview
The v-intersect directive uses the Intersection Observer API to detect when an element enters or exits the viewport. Perfect for lazy loading, infinite scroll, and scroll-based animations.
import { Intersect } from 'vuetify/directives'
Registration
Global Registration
import { createApp } from 'vue'
import { Intersect } from 'vuetify/directives'
const app = createApp({})
app.directive('intersect', Intersect)
Local Registration
<script setup>
import { Intersect } from 'vuetify/directives'
const vIntersect = Intersect
</script>
<!-- Simple handler -->
<div v-intersect="handler"></div>
<!-- With options -->
<div v-intersect="{ handler, options }"></div>
<!-- With modifiers -->
<div v-intersect.once.quiet="handler"></div>
Value Types
Function Handler
v-intersect="(
isIntersecting: boolean,
entries: IntersectionObserverEntry[],
observer: IntersectionObserver
) => void"
Object Configuration
v-intersect="{
handler: ObserveHandler
options?: IntersectionObserverInit
}"
Parameters
Function called when intersection state changestype ObserveHandler = (
isIntersecting: boolean,
entries: IntersectionObserverEntry[],
observer: IntersectionObserver
) => void
Intersection Observer optionsViewport element (defaults to browser viewport)
Margin around root (e.g., ‘10px’, ‘10px 20px’)
options.threshold
number | number[]
default:"0"
Visibility threshold (0 to 1, or array of thresholds)
Modifiers
Trigger handler only once, then automatically unobserve
Don’t trigger handler on initial mount, only on actual intersection changes
Usage Examples
Basic Visibility Detection
<script setup>
import { ref } from 'vue'
const isVisible = ref(false)
function onIntersect(isIntersecting: boolean) {
isVisible.value = isIntersecting
}
</script>
<template>
<div>
<div style="height: 100vh">Scroll down</div>
<div v-intersect="onIntersect">
<p v-if="isVisible">I'm visible!</p>
<p v-else>I'm hidden!</p>
</div>
</div>
</template>
Lazy Load Images
<script setup>
import { ref } from 'vue'
const imageSrc = ref('')
const actualSrc = 'https://example.com/image.jpg'
function loadImage(isIntersecting: boolean) {
if (isIntersecting) {
imageSrc.value = actualSrc
}
}
</script>
<template>
<img
v-intersect.once="loadImage"
:src="imageSrc || 'placeholder.jpg'"
alt="Lazy loaded image"
/>
</template>
Infinite Scroll
<script setup>
import { ref } from 'vue'
const items = ref(Array.from({ length: 20 }, (_, i) => i))
const loading = ref(false)
async function loadMore(isIntersecting: boolean) {
if (isIntersecting && !loading.value) {
loading.value = true
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
const newItems = Array.from(
{ length: 20 },
(_, i) => items.value.length + i
)
items.value.push(...newItems)
loading.value = false
}
}
</script>
<template>
<div>
<v-card v-for="item in items" :key="item">
Item {{ item }}
</v-card>
<div v-intersect="loadMore" style="height: 100px">
<v-progress-circular v-if="loading" indeterminate />
</div>
</div>
</template>
Scroll-Based Animation
<script setup>
import { ref } from 'vue'
const animated = ref(false)
function onIntersect(isIntersecting: boolean) {
if (isIntersecting) {
animated.value = true
}
}
</script>
<template>
<div
v-intersect.once="onIntersect"
:class="{ 'fade-in': animated }"
>
Animated content
</div>
</template>
<style scoped>
.fade-in {
animation: fadeIn 1s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
With Custom Threshold
<script setup>
import { ref } from 'vue'
const visibility = ref(0)
function onIntersect(
isIntersecting: boolean,
entries: IntersectionObserverEntry[]
) {
if (entries[0]) {
visibility.value = Math.round(entries[0].intersectionRatio * 100)
}
}
</script>
<template>
<div
v-intersect="{
handler: onIntersect,
options: {
threshold: [0, 0.25, 0.5, 0.75, 1.0]
}
}"
>
Visible: {{ visibility }}%
</div>
</template>
Viewport Tracking
<script setup>
import { ref } from 'vue'
const sections = ref([
{ id: 'intro', visible: false },
{ id: 'features', visible: false },
{ id: 'pricing', visible: false },
{ id: 'contact', visible: false }
])
function createHandler(id: string) {
return (isIntersecting: boolean) => {
const section = sections.value.find(s => s.id === id)
if (section) {
section.visible = isIntersecting
}
}
}
</script>
<template>
<div>
<nav>
<a
v-for="section in sections"
:key="section.id"
:class="{ active: section.visible }"
>
{{ section.id }}
</a>
</nav>
<div
v-for="section in sections"
:key="section.id"
v-intersect="createHandler(section.id)"
style="height: 100vh"
>
{{ section.id }}
</div>
</div>
</template>
With Root Margin
<script setup>
import { ref } from 'vue'
const isNear = ref(false)
function onIntersect(isIntersecting: boolean) {
isNear.value = isIntersecting
}
</script>
<template>
<!-- Triggers 200px before element enters viewport -->
<div
v-intersect="{
handler: onIntersect,
options: {
rootMargin: '200px'
}
}"
>
Content preloads early
</div>
</template>
Lazy Load Component
<script setup>
import { ref, defineAsyncComponent } from 'vue'
const shouldLoad = ref(false)
const HeavyComponent = defineAsyncComponent(() =>
shouldLoad.value
? import('./HeavyComponent.vue')
: Promise.resolve({ template: '<div></div>' })
)
function onIntersect(isIntersecting: boolean) {
if (isIntersecting) {
shouldLoad.value = true
}
}
</script>
<template>
<div v-intersect.once="onIntersect">
<HeavyComponent v-if="shouldLoad" />
<v-skeleton-loader v-else type="card" />
</div>
</template>
Video Autoplay on Scroll
<script setup>
import { ref } from 'vue'
const videoRef = ref<HTMLVideoElement | null>(null)
function onIntersect(isIntersecting: boolean) {
if (videoRef.value) {
if (isIntersecting) {
videoRef.value.play()
} else {
videoRef.value.pause()
}
}
}
</script>
<template>
<video
ref="videoRef"
v-intersect="onIntersect"
src="video.mp4"
muted
loop
/>
</template>
Progress Indicator
<script setup>
import { ref } from 'vue'
const progress = ref(0)
function updateProgress(
isIntersecting: boolean,
entries: IntersectionObserverEntry[]
) {
progress.value = entries[0]?.intersectionRatio || 0
}
</script>
<template>
<div>
<v-progress-linear :model-value="progress * 100" />
<div
v-intersect="{
handler: updateProgress,
options: { threshold: Array.from({ length: 101 }, (_, i) => i / 100) }
}"
style="height: 200vh"
>
Tall content
</div>
</div>
</template>
Intersection Observer Entry
The entries parameter provides detailed information:
interface IntersectionObserverEntry {
boundingClientRect: DOMRectReadOnly
intersectionRatio: number // 0 to 1
intersectionRect: DOMRectReadOnly
isIntersecting: boolean
rootBounds: DOMRectReadOnly | null
target: Element
time: number
}
Browser Support
The directive automatically checks for Intersection Observer support. If not available:
- No error is thrown
- Handler is never called
- Element renders normally
For older browsers, use a polyfill:
import 'intersection-observer'
Performance Tips
- Use
.once modifier when appropriate
- Use higher thresholds for better performance
- Limit number of observed elements
- Use
rootMargin to trigger early
- Debounce expensive handlers
- Requires browser support for Intersection Observer API
- Automatically cleans up on unmount
- Works with conditional rendering (v-if)
- Handler may be called immediately on mount (unless
.quiet)
- Multiple instances can observe the same element
- Not triggered during SSR
See Also