Defer Rendering of Vue Components Using IntersectionObserver – vuedefer

Install & Download:

Description:

vuedefer is a Vue component library that defers child component mounting until those components scroll into the browser viewport.

It wraps Vue’s internal render function to intercept the mount lifecycle, so components below the fold stay unmounted until the user actually reaches them.

Features

  • 🚀 Lazy Mount: Components mount only when they enter the viewport.
  • ❄️ Update Freezing: When a component exits the viewport, its render function swaps out for a static subtree snapshot.
  • 🔁 Render Restoration: Re-entering the viewport restores the original render function, and the component resumes normal reactive behavior.
  • 📦 Zero Dependencies: The package relies entirely on Vue internals and the native browser IntersectionObserver API.
  • 🎛️ Configurable Intersection Options: root, rootMargin, and threshold props map directly to IntersectionObserver constructor options.
  • 🧩 Fallback Slot: A named #fallback slot holds placeholder content that renders in place of the component before it mounts.

Use Cases

  • Lazy Loading Below‑the‑Fold Content: For long pages with many components, use vuedefer to defer rendering of content not initially visible.
  • Optimizing Heavy Components: Components like charts, data tables, or video players can be lazy‑loaded to improve initial page load.
  • Infinite Scroll Feeds: When implementing infinite scroll, lazy‑load new items as they come into view.
  • Conditional Rendering Based on Visibility: Use vuedefer to mount components only when they become visible. This reduces memory usage.

How to Use It

Installation

Install vuedefer from npm using your preferred package manager.

npm install vuedefer
pnpm add vuedefer
yarn add vuedefer

Basic Lazy Loading

Import LazyRender from vuedefer and wrap any child component you want to defer. Place placeholder markup in the #fallback slot. That slot renders until the wrapper element enters the viewport.

<script setup lang="ts">
import { LazyRender } from 'vuedefer'
import HeavyWidget from './HeavyWidget.vue'
</script>
<template>
  <div class="page">
    <header>Page Header</header>
    <!-- Content above the fold renders immediately -->
    <section class="hero">Hero content here</section>
    <!-- HeavyWidget mounts only when this wrapper scrolls into view -->
    <LazyRender>
      <HeavyWidget />
      <template #fallback>
        <div class="skeleton-block">Loading...</div>
      </template>
    </LazyRender>
  </div>
</template>

The #fallback slot is optional. If you omit it, nothing renders in the placeholder position before the component mounts.

Detecting Mount State

Use Vue’s @vue:mounted hook on the child component to track when the deferred mount fires. This works for status indicators, analytics events, or triggering follow-up actions.

<script setup lang="ts">
import { ref } from 'vue'
import { LazyRender } from 'vuedefer'
import DataTable from './DataTable.vue'
const tableMounted = ref(false)
</script>
<template>
  <div>
    <p>Table status: {{ tableMounted ? 'Loaded' : 'Waiting...' }}</p>
    <div style="height: 100vh;">Scroll down</div>
    <LazyRender>
      <DataTable @vue:mounted="tableMounted = true" />
      <template #fallback>
        <div class="table-skeleton" />
      </template>
    </LazyRender>
  </div>
</template>

Custom Scroll Container and Root Margin

By default, LazyRender observes against the browser viewport. Pass a reference to any scrollable container element via the root prop to observe against that element instead. The root-margin prop expands or shrinks the detection area using standard CSS margin syntax.

In the example below, the observer watches a fixed-height scrollable div rather than the window, and pre-triggers mounting 100px before the component actually enters the container.

<script setup lang="ts">
import { ref } from 'vue'
import { LazyRender } from 'vuedefer'
import ExpensiveChart from './ExpensiveChart.vue'
const panel = ref<HTMLElement | null>(null)
</script>
<template>
  <div ref="panel" class="scroll-panel" style="height: 500px; overflow-y: scroll;">
    <LazyRender :root="panel" root-margin="100px">
      <ExpensiveChart />
      <template #fallback>
        <div class="chart-skeleton">Loading chart...</div>
      </template>
    </LazyRender>
  </div>
</template>

Visibility Threshold

The threshold prop accepts a number between 0 and 1. A value of 0 fires the callback the moment a single pixel of the component enters the detection area. A value of 1 fires only once the component is fully visible.

<template>
  <!-- Mount VideoPlayer only when at least 50% of it is visible -->
  <LazyRender :threshold="0.5">
    <VideoPlayer src="/media/intro.mp4" />
    <template #fallback>
      <div class="video-placeholder">Preparing video...</div>
    </template>
  </LazyRender>
</template>

Pass an array of numbers to receive callbacks at multiple visibility milestones. This suits progressive-rendering components that respond to partial visibility states.

<template>
  <LazyRender :threshold="[0, 0.25, 0.5, 0.75, 1]">
    <ProgressiveImage src="/images/hero.jpg" />
  </LazyRender>
</template>

Custom Wrapper Tag

The wrapper element defaults to div. Change it with the tag prop to match semantic document structure.

<template>
  <LazyRender tag="section">
    <ArticleBody />
    <template #fallback>
      <div class="article-skeleton" />
    </template>
  </LazyRender>
</template>

API Reference

Props

PropTypeDefaultDescription
tagstring'div'HTML tag name for the wrapper element that IntersectionObserver watches.
rootElement | Document | ShadowRoot | nullnullThe scroll container to observe against. null targets the browser viewport.
rootMarginstringundefinedExpands or shrinks the detection boundary around the root. Accepts standard CSS margin syntax: '10px', '10px 20px', etc.
thresholdnumber | number[]undefinedVisibility ratio at which the observer fires. 0 triggers on first pixel visible; 1 triggers when fully visible. An array triggers at each listed ratio.

Slots

SlotDescription
defaultThe component or markup to mount lazily when it enters the viewport.
fallbackPlaceholder content that renders in the wrapper position before the default slot mounts.

Related Resources

  • IntersectionObserver: MDN reference for the native API that vuedefer wraps internally.
  • vue-lazyload: Image-focused lazy loading library for Vue with directive-based syntax.
  • VueUse — useIntersectionObserver: Composable from VueUse that exposes IntersectionObserver as a reactive hook for building custom lazy behaviors.
  • nuxt-lazy-hydrate: Server-side rendering companion that defers client-side hydration of components using similar intersection-based logic, scoped to Nuxt.

FAQs

Q: What happens to component state when it leaves the viewport?
A: The component stays mounted in the Vue component tree. vuedefer only swaps out the render function for a static snapshot of the last subtree. Reactive data inside the component continues to update in memory; it just stops triggering DOM patches until the component re-enters view and the original render function restores.

Q: Can I use LazyRender inside a v-for loop?
A: Yes. Each LazyRender instance creates its own IntersectionObserver, so wrapping repeated list items works independently per item. For very long lists (hundreds of items), weigh the per-observer memory cost against your target device constraints.

Q: Does the #fallback slot maintain the same dimensions as the actual component?
A: Only if you style it that way. vuedefer renders whatever markup you place in #fallback without applying automatic sizing. Match the fallback’s height to the expected component height to prevent layout shift when the real component mounts.

Q: Is SSR supported?
A: IntersectionObserver does not exist in Node.js environments. On a server render pass, the default slot content will not mount, and only the #fallback content renders. Client-side hydration then activates the observer as normal. Test your fallback markup to confirm it produces acceptable server-rendered output.

Add Comment