Cutting Image Payloads in Svelte with Intersection Observer
Delivering images only when they scroll into view is one of the simplest wins for page speed. In a Svelte app, the Intersection Observer API paired with the onLoad event makes this straightforward to implement as a reusable component set. The approach breaks down into three small, single-purpose components: an observer that watches for viewport entry, a loader that decides when to render an image, and the image element itself that fades in once its file has fully downloaded.
The technique proved effective in production on Shop Ireland, a Svelte and Sapper application. The homepage was suffering because the browser fetched off-screen images before they were needed. Adding lazy loading cut that waste; Svelte’s ahead-of-time compilation already keeps JavaScript lean, and deferring image downloads trimmed the remaining payload.
Scaffolding the project
If you don’t have an existing Svelte project, start a fresh one and run it locally:
npx degit sveltejs/template my-svelte-project
cd my-svelte-project
npm install
npm run dev
The default app runs at http://localhost:5000. Create a src/components/Image directory to hold the three components that follow.
Creating the IntersectionObserver component
The wrapper component encapsulates the Intersection Observer API. It notifies child content when an element enters the viewport and can be configured to fire only once. The component mirrors the one used on svelte.dev:
<script>
import { onMount } from 'svelte';
export let once = false;
export let top = 0;
export let bottom = 0;
export let left = 0;
export let right = 0;
let intersecting = false;
let container;
onMount(() => {
if (typeof IntersectionObserver !== 'undefined') {
const rootMargin = `${bottom}px ${left}px ${top}px ${right}px`;
const observer = new IntersectionObserver(entries => {
intersecting = entries[0].isIntersecting;
if (intersecting && once) {
observer.unobserve(container);
}
}, {
rootMargin
});
observer.observe(container);
return () => observer.unobserve(container);
}
// The following is a fallback for older browsers
function handler() {
const bcr = container.getBoundingClientRect();
intersecting = (
(bcr.bottom + bottom) > 0 &&
(bcr.right + right) > 0 &&
(bcr.top - top) < window.innerHeight &&
(bcr.left - left) < window.innerWidth
);
if (intersecting && once) {
window.removeEventListener('scroll', handler);
}
}
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
});
</script>
<style>
div {
width: 100%;
height: 100%;
}
</style>
<div bind:this={container}>
<slot {intersecting}></slot>
</div>
The component exposes a once prop that guarantees the callback only fires the first time the element becomes visible, and optional top, right, bottom, and left props that set margin boundaries for the intersection check. For this demo, once matters most: images should load exactly once, as they appear.
All the logic lives inside onMount. It first sets up an Intersection Observer to monitor the element. For older browsers lacking that API, it falls back to a scroll event listener that checks visibility manually, and removes that listener once the element is visible and once is true.
Building the ImageLoader layer
The loader component bridges the observer and the actual <img>. It accepts the src and alt props you would normally pass to an image element:
<script>
export let src
export let alt
import IntersectionObserver from './IntersectionObserver.svelte'
import Image from './Image.svelte'
</script>
<IntersectionObserver once={true} let:intersecting={intersecting}>
{#if intersecting}
<Image {alt} {src} />
{/if}
</IntersectionObserver>
The key here is Svelte’s slot props. The wrapper component makes its internal intersecting state available to whatever it contains. In the IntersectionObserver, that value is passed along in the markup:
<slot {intersecting}></slot>
Inside ImageLoader, the slot exposes it with the let:intersecting directive:
<IntersectionObserver once={true} let:intersecting={intersecting}>
That value then gates rendering of the image component:
<IntersectionObserver once={true} let:intersecting={intersecting}>
{#if intersecting}
<Image {alt} {src} />
{/if}
</IntersectionObserver>
When the intersection occurs, the Image component mounts and receives both props.
Revealing the finished image
The final component, Image.svelte, renders the markup and handles the fade-in. It receives the same props, but adds state and a DOM reference:
<script>
export let src
export let alt
import { onMount } from 'svelte'
let loaded = false
let thisImage
onMount(() => {
thisImage.onload = () => {
loaded = true
}
})
</script>
<style>
img {
height: 200px;
opacity: 0;
transition: opacity 1200ms ease-out;
}
img.loaded {
opacity: 1;
}
</style>
<img {src} {alt} class:loaded bind:this={thisImage} />
The script starts by exporting src and alt, then tracks a loaded variable and a thisImage reference. Inside onMount, it assigns a callback to thisImage.onload; once the browser finishes downloading the image file, the callback flips loaded to true.
In the markup, an <img> tag starts with opacity: 0. The class:loaded directive switches it to full opacity when the image is ready, and the CSS transition handles a smooth fade over 1200ms — tweak that duration to taste:
<img {src} {alt} class:loaded bind:this={thisImage} />
The bind:this directive ties the DOM node to thisImage so onload can be registered.
Leveraging native lazy loading
Native loading="lazy" support is broadly available in modern browsers but hasn’t landed everywhere yet. A capability check lets modern browsers handle lazy loading themselves while the custom path covers the rest.
In ImageLoader.svelte, import onMount and detect support:
import { onMount } from 'svelte'
let nativeLoading = false
// Determine whether to bypass our intersecting check
onMount(() => {
if ('loading' in HTMLImageElement.prototype) {
nativeLoading = true
}
})
Then amend the conditional render to account for that flag:
{#if intersecting || nativeLoading}
<Image {alt} {src} />
{/if}
In Image.svelte, add the native attribute to the <img> element:
<img {src} {alt} class:loaded bind:this={thisImage} />
With that, current and future browsers skip the observer logic, while older ones still benefit from the JavaScript fallback.
Wiring it together
Finally, import and use the loader in App.svelte as you would any other component:
<script>
import ImageLoader from './components/Image/ImageLoader.svelte';
</script>
<ImageLoader src="OUR_IMAGE_URL" alt="Our image"></ImageLoader>
The component can now be reused anywhere in the Svelte app. The complete demo is available on GitHub, and the pattern is in production on Shop Ireland’s homepage, category, and search pages.



