Lazy Loading Guide

vd3 ships useLazyLoad, an IntersectionObserver composable that defers work until an element scrolls into view — either firing a callback (observe) or fetching-and-injecting a content section (loadSection). Pair it with the bundler tactics below — route-level code splitting, async components, and native lazy images — so you ship only what a route needs and reveal the rest on demand.

Reveal on scroll (useLazyLoad)

Scroll inside the box below. The target starts hidden and clipped out of view; the moment 25% of it intersects the viewport, observe() runs its callback exactly once and reveals the content — then unobserves itself.

Keep scrolling down inside this box…

…the target is further below…
Revealed!

observe() fired its callback on first intersection.

Status: pending
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useLazyLoad } from "@vanduo-oss/vd3";

const target = ref<HTMLElement | null>(null);
const revealed = ref(false);
const { observe } = useLazyLoad();

onMounted(() => {
  if (!target.value) return;
  // Fires once when the element first scrolls into view, then auto-unobserves.
  observe(
    target.value,
    () => {
      revealed.value = true;
    },
    { threshold: 0.25, rootMargin: "0px" },
  );
});
</script>

<template>
  <div ref="target" :class="{ 'is-loaded': revealed }">
    Deferred content — revealed on first intersection.
  </div>
</template>
Fetch a section on demand

loadSection shows a skeleton, then fetches and injects the (sanitized) HTML the first time the container is seen.

<script setup lang="ts">
import { ref } from "vue";
import { useLazyLoad } from "@vanduo-oss/vd3";

const panel = ref<HTMLElement | null>(null);
const { loadSection } = useLazyLoad();

function deferPanel() {
  if (!panel.value) return;
  // Shows a skeleton, then fetches + injects the (sanitized) HTML the first
  // time the panel scrolls into view. 10s abort timeout; https/same-origin only.
  loadSection("/partials/pricing.html", panel.value, {
    placeholder: "skeleton", // or "spinner", or a custom HTML string
    onLoaded: (el) => console.log("injected into", el),
    onError: (err) => console.warn("lazy fetch failed", err),
  });
}
</script>
Declarative wiring

Pass a root ref and every [data-vd-lazy] descendant is auto-wired on mount.

<script setup lang="ts">
import { ref } from "vue";
import { useLazyLoad } from "@vanduo-oss/vd3";

// Pass a root ref and every [data-vd-lazy] descendant is auto-wired on mount.
const root = ref<HTMLElement | null>(null);
useLazyLoad(root);
</script>

<template>
  <div ref="root">
    <section data-vd-lazy="/partials/reviews.html"
             data-vd-lazy-placeholder="spinner"></section>
  </div>
</template>
Route-level code splitting
// Vite splits each route into its own chunk — loaded on demand.
const routes = [
  { path: '/components/datepicker',
    component: () => import('@/pages/components/Datepicker.vue') },
  { path: '/changelog',
    component: () => import('@/pages/changelog.vue') }, // heavy → its own chunk
];

The ~4,000-line changelog, for example, lives in its own chunk and never loads on other pages.

Async components
// Defer a heavy widget until it is actually rendered.
import { defineAsyncComponent } from 'vue';
const VdChart = defineAsyncComponent(
  () => import('@/components/VdChart.vue'),
);
Native lazy assets
<!-- Native lazy images need no JS at all -->
<img src="hero.jpg" loading="lazy" decoding="async" alt="…">
useLazyLoad API

Returned methods

MethodDescription
observe(el, cb, options?)Observe an element; the callback fires once when it first intersects the viewport, then it auto-unobserves. Without IntersectionObserver (SSR / old browsers) the callback runs immediately.
unobserve(el)Cancel a still-pending observation for an element.
loadSection(url, container, options?)Render a placeholder, then on first intersection fetch a same-origin / https URL (10s timeout), inject the sanitized HTML, and dispatch lifecycle events.
useLazyLoad(root?)Pass a root element ref to auto-wire every [data-vd-lazy] descendant on mount (URL from data-vd-lazy, placeholder from data-vd-lazy-placeholder, state stamped on data-vd-lazy-state).

observe() options

OptionDescription
:thresholdIntersectionObserver threshold (default 0).
:rootMarginIntersectionObserver rootMargin (default "0px").

loadSection() options

OptionDescription
:placeholder"skeleton" (default) or "spinner" render the shipped loader markup; any other string is treated as custom HTML and sanitized before injection.
:threshold / :rootMarginIntersectionObserver tuning for the deferred fetch.
onLoaded(container)Called with the container after the response is injected.
onError(error)Called with the error when the fetch or URL guard fails.

Events (loadSection)

Dispatched on the container as bubbling CustomEvents.

EventDescription
lazysection:loadingFired immediately (with the placeholder in place). detail: { url }.
lazysection:loadedFired after the sanitized HTML is injected. detail: { url }.
lazysection:errorFired on a failed fetch or a blocked URL (an inline .vd-alert-error is also rendered). detail: { url, error }.
Tactics at a glance
TacticWhat it does
useLazyLoad().observeActivate effects (glass, parallax, counters) only when scrolled into view.
useLazyLoad().loadSectionDefer a fetch-and-inject content section until the container is seen.
Route-level splittingDynamic import() in the router — each page is its own chunk.
Async componentsdefineAsyncComponent for heavy below-the-fold widgets.
Native lazy imagesloading="lazy" — zero JavaScript, browser-driven.