vue-viewport-render
v0.1.0
Published
Vue 3 component that mounts content on viewport entry and exposes live visibility, so off-screen work can be gated at the data source.
Maintainers
Readme
vue-viewport-render
A Vue 3 component that defers mounting until its slot enters the viewport, and keeps publishing live visibility afterwards so descendants can stop doing work while off-screen.
Zero runtime dependencies. Only vue as a peer.
Why
Two separate problems get conflated under "lazy render":
- Don't mount what nobody has seen yet. IntersectionObserver solves this directly.
- Don't keep recomputing what nobody is looking at. This one cannot be solved by stopping a parent from re-rendering — every descendant component owns its own reactive effect, so a grandchild subscribed to a store keeps re-rendering no matter what its parent does. It has to be solved at the data source, and for that the component needs to tell its descendants whether it is on screen.
LazyRender handles the first and gives you the hook for the second.
Installation
npm install vue-viewport-renderUsage
<script setup>
import { LazyRender } from 'vue-viewport-render'
</script>
<template>
<LazyRender tag="section" class="chart-slot" root-margin="200px">
<HeavyChart />
<template #fallback>
<ChartSkeleton :height="320" />
</template>
</LazyRender>
</template>The wrapper element stays in the DOM after activation, so tag, class, style and any other fallthrough attribute keep applying, and the wrapper is where you reserve height to avoid layout shift:
<LazyRender style="min-height: 320px"><HeavyChart /></LazyRender>Gating work while off-screen
Read visibility from any depth and gate the subscription itself:
<script setup>
import { shallowRef, watchEffect } from 'vue'
import { useLazyRenderVisible } from 'vue-viewport-render'
import { store } from './store'
const visible = useLazyRenderVisible()
const chart = shallowRef()
watchEffect((onCleanup) => {
if (!visible.value) return
const redraw = () => chart.value?.render(store.data)
redraw()
const off = store.onChange(redraw)
onCleanup(off)
})
</script>The same check stops polling, requestAnimationFrame loops, and video playback. A scoped slot prop is available if you prefer not to use inject:
<LazyRender>
<template #default="{ isVisible }">
<HeavyChart :active="isVisible" />
</template>
</LazyRender>Scroll containers
root accepts an element, a ref to one, a component instance, or a getter returning any of those. It is reactive — the observer is rebuilt when the root changes, including the common case where a parent template ref is still null while the child sets up:
<script setup>
import { ref } from 'vue'
const scroller = ref(null)
</script>
<template>
<div ref="scroller" class="scroller">
<LazyRender :root="scroller" root-margin="100px"><Panel /></LazyRender>
</div>
</template>Mount-only mode
When you only want deferral and not off-screen gating — a long feed of cards, for instance — set once so the observer is disconnected as soon as the content activates:
<LazyRender once><FeedCard /></LazyRender>Note that once and useLazyRenderVisible are mutually exclusive: with the observer gone, visibility is frozen at true.
API
LazyRender props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| tag | string | 'div' | Wrapper element. Persists after activation. |
| root | Element \| Ref<Element> \| Component \| (() => Element) | null | Observer root. null means the browser viewport. Reactive. |
| rootMargin | string | — | CSS margin syntax. Reactive. |
| threshold | number \| number[] | 0 | Read once at setup, deliberately not reactive — an inline array literal in a template is a new reference on every render and would rebuild the observer each time. |
| once | boolean | false | Disconnect the observer after first activation. |
Events
| Event | Payload | Notes |
| --- | --- | --- |
| change | boolean | Fires only when visibility actually flips, so multiple thresholds do not produce duplicate events. |
Slots
| Slot | Props | Notes |
| --- | --- | --- |
| default | { isVisible, activated } | Rendered from the first activation onwards, and never swapped back to the fallback. |
| fallback | — | Rendered before the first activation. |
Exposed
isVisible and activated, both refs, via a component template ref.
useLazyRenderVisible()
Returns a readonly Ref<boolean> — the live visibility of the nearest LazyRender ancestor, or true when called outside one.
useIntersectionVisibility(target, options)
The composable underneath, exported for building your own deferral logic.
| Option | Type | Notes |
| --- | --- | --- |
| root | see above | Reactive. |
| rootMargin | string \| Ref<string> \| (() => string) | Reactive. |
| threshold | number \| number[] | Read once. |
| onChange | (isVisible: boolean) => void | Called only on actual flips. |
Returns { isVisible, isSupported, stop }. isSupported is false where IntersectionObserver is unavailable. Cleanup is automatic on effect scope disposal.
Behaviour notes
SSR. The server and the client's first frame both render fallback, so hydration matches. Nothing crashes on the server. The trade-off is that crawlers receive the skeleton, so do not wrap SEO-critical content. When IntersectionObserver is unavailable in the browser, content is released on mount rather than never appearing.
No unmounting. Once activated, content stays mounted. This defers work; it does not reclaim memory. For long lists use a virtual scroller.
Layout shift. Nothing reserves space for you. Set a height on the wrapper or on the fallback content.
License
MIT
