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…
observe() fired its callback on first intersection.
<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
| Method | Description |
|---|---|
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
| Option | Description |
|---|---|
:threshold | IntersectionObserver threshold (default 0). |
:rootMargin | IntersectionObserver rootMargin (default "0px"). |
loadSection() options
| Option | Description |
|---|---|
:placeholder | "skeleton" (default) or "spinner" render the shipped loader markup; any other string is treated as custom HTML and sanitized before injection. |
:threshold / :rootMargin | IntersectionObserver 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.
| Event | Description |
|---|---|
lazysection:loading | Fired immediately (with the placeholder in place). detail: { url }. |
lazysection:loaded | Fired after the sanitized HTML is injected. detail: { url }. |
lazysection:error | Fired on a failed fetch or a blocked URL (an inline .vd-alert-error is also rendered). detail: { url, error }. |
Tactics at a glance
| Tactic | What it does |
|---|---|
| useLazyLoad().observe | Activate effects (glass, parallax, counters) only when scrolled into view. |
| useLazyLoad().loadSection | Defer a fetch-and-inject content section until the container is seen. |
| Route-level splitting | Dynamic import() in the router — each page is its own chunk. |
| Async components | defineAsyncComponent for heavy below-the-fold widgets. |
| Native lazy images | loading="lazy" — zero JavaScript, browser-driven. |