Lazy Loading Images in Svelte

Avatar of Donovan Hutchinson
Donovan Hutchinson on (Updated on )

📣 Freelancers, Developers, and Part-Time Agency Owners: Kickstart Your Own Digital Agency with UACADEMY Launch by UGURUS 📣

One easy way to improve the speed of a website is to only download images only when they’re needed, which would be when they enter the viewport. This “lazy loading” technique has been around a while and there are lots of great tutorials on how to implement it.

But even with all the resources out there, implementing lazy loading can look different depending on the project you’re working in or the framework you’re using. In this article, I’ll use the Intersection Observer API alongside the onLoad event to lazy load images with the Svelte JavaScript framework.

Check out Tristram Tolliday’s introduction to Svelte if you’re new to the framework.

Let’s work with a real-life example

I put this approach together while testing the speed on a Svelte and Sapper application I work on, Shop Ireland. One of our goals is to make the thing as fast as we possible can. We hit a point where the homepage was taking a performance hit because the browser was downloading a bunch of images that weren’t even on the screen, so naturally, we turned to lazy loading them instead.

Svelte is already pretty darn fast because all of the code is compiled in advance. But once we tossed in lazy loading for images, things really started speeding up.

This is what we’re going to work on together. Feel free to grab the final code for this demo from GitHub and read along for an explanation of how it works.

This is where we’ll end up by the end:

Let’s quickly start up Svelte

You might already have a Svelte app you’d like to use, but if not, let’s start a new Svelte project and work on it locally. From the command line:

npx degit sveltejs/template my-svelte-project
cd my-svelte-project
npm install
npm run dev

You should now have a beginner app running on http://localhost:5000.

Adding the components folder

The initial Svelte demo has an App.svelte file but no components just yet. Let’s set up the components we need for this demo. There is no components  folder, so let’s create one in the src folder. Inside that folder, create an Image folder — this will hold our components for this demo.

We’re going to have our components do two things. First, they will check when an image enters the viewport. Then, when an image does enter, the components will wait until the image file has loaded before showing it.

The first component will be an <IntersectionObserver> that wraps around the second component, an <ImageLoader>. What I like about this setup is that it allows each component to be focused on doing one thing instead of trying to pack a bunch of operations in a single component.

Let’s start with the <IntersectionObserver> component.

Observing the intersection

Our first component is going to be a working implementation of the Intersection Observer API. The Intersection Observer is a pretty complex thing but the gist of it is that it watches a child element and informs us when it enters the bounding box of its parent. Hence images: they can be children of some parent element and we can get a heads up when they scroll into view.

While it’s definitely a great idea to get acquainted with the ins and outs of the Intersection Observer API — and Travis Almand has an excellent write-up of it — we’re going to make use of a handy Svelte component that Rich Harris put together for svelte.dev.

We’ll set this up  first before digging into what exactly it does. Create a new IntersectionObserver.svelte file and drop it into the src/components/Image folder. This is where we’ll define the component with the following code:

<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>

We can use this component as a wrapper around other components, and it will determine for us whether the wrapped component is intersecting with the viewport.

If you’re familiar with the structure of Svelte components, you’ll see it follows a pattern that starts with scripts, goes into styles, then ends with markup. It sets some options that we can pass in, including a once property, along with numeric values for the top, right, bottom and left distances from the edge of the screen that define the point where the intersection begins.

We’ll ignore the distances but instead make use of the once property. This will ensure the images only load once, as they enter the viewport.

The main logic of the component is within the onMount section. This sets up our observer, which is used to check our element to determine if it’s “intersecting” with the visible area of the screen.

For older browsers it also attaches a scroll event to check whether the element is visible as we scroll, and then it’ll remove this listener if we’ve determined that it is viable and that once is true.

Loading the images

Let’s use our <IntersectionObserver> component to conditionally load images by wrapping it around an <ImageLoader> component. Again, this is the component that receives a notification from the <IntersectionOberserver> so it knows it’s time to load an image.

That means we’ll need a new component file in components/Image. Let’s call it ImageLoader.svelte. Here’s the code we want in it:

<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>

This component takes some image-related props — src and alt — that we will use to create the actual markup for an image. Notice that we’re importing two components in the scripts section, including the <IntersectionObserver> we just created and another one called <Image> that we haven’t created yet, but will get to in a moment.

The <IntersectionObserver> is put to work by acting as a wrapping around the soon-to-be-created <Image> component. Check out those properties on it.  We are setting once to true, so the image only loads the first time we see it.

Then we make use of Svelte’s slot props. What are those? Let’s cover that next.

Slotting property values

Wrapping component, like our <IntersectionObserver> are handy for passing props to the children it contains. Svelte gives us something called slot props to make that happen.

In our <IntersectionObserver> component you may have noticed this line:

<slot {intersecting}></slot>

This is passing the intersecting prop into whatever component we give it. In this case, our <ImageLoader> component receives the prop when it uses the wrapper. We access the prop using let:intersecting={intersecting} like so:

<IntersectionObserver once={true} let:intersecting={intersecting}>

We can then use the intersecting value to determine when it’s time to load an <Image> component. In this case, we’re using an if condition to check for when it’s go time:

<IntersectionObserver once={true} let:intersecting={intersecting}>
  {#if intersecting}
    <Image {alt} {src} />
  {/if}
</IntersectionObserver> 

If the intersection is happening, the <Image> is loaded and receives the alt and src props. You can learn a bit more about slot props in this Svelte tutorial.

We now have the code in place to show an <Image> component when it is scrolled onto the screen. Let’s finally get to building the component.

Showing images on load

Yep, you guessed it: let’s add an Image.svelte file to the components/Image folder for our <Image> component. This is the component that receives our alt and src props and sets them on an <img> element.

Here’s the component code:

<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} />

Right off the bat, we’re receiving the alt and src props before defining two new variables: loaded to store whether the image has loaded or not, and thisImage to store a reference to the img DOM element itself.

We’re also using a helpful Svelte method called onMount. This gives us a way to call functions once a component has been rendered in the DOM. In this case, we’re set a callback for thisImage.onload. In plain English, that means it’s executed when the image has finished loading, and will set the loaded variable to a true value.

We’ll use CSS to reveal the image and fade it into view. Let’s give set an opacity: 0 on images so they are initially invisible, though technically on the page. Then, as they intersect the viewport and the <ImageLoader> grants permission to load the image, we’ll set the image to full opacity. We can make it a smooth transition by setting the transition property on image. The demo sets the transition time to 1200ms but you can speed it up or slow it down as needed.

That leads us to the very last line of the file, which is the markup for an <img> element.

<img {src} {alt} class:loaded bind:this={thisImage} />

This uses class:loaded to conditionally apply a .loaded class if the loaded variable is true. It also uses the bind:this method to associate this DOM element with the thisImage variable.

Native lazy loading

While support for native lazy loading in browsers is almost here, it’s not yet supported across all the current stable versions. We can still add support for it using a simple capability check.

In our ImageLoader.svelte file we can bring in the onMount function, and within it, check to see if our browser supports lazy loading.

import { onMount } from 'svelte'

let nativeLoading = false
// Determine whether to bypass our intersecting check
onMount(() => {
  if ('loading' in HTMLImageElement.prototype) {
    nativeLoading = true
  }
})

We then adjust our if condition to include this nativeLoading boolean.

{#if intersecting || nativeLoading}
  <Image {alt} {src} />
{/if}

Lastly, in Image.svelte, we tell our browser to use lazy loading by adding loading="lazy" to the <img> element.

<img {src} {alt} class:loaded bind:this={thisImage} loading="lazy" />

This lets modern and future browsers bypass our code and take care of the lazy loading natively.

Let’s hook it all up!

Alright, it’s time to actually use our component. Crack open the App.svelte file and drop in the following code to import our component and use it:

<script>
  import ImageLoader from './components/Image/ImageLoader.svelte';
</script>


<ImageLoader src="OUR_IMAGE_URL" alt="Our image"></ImageLoader>

Here’s the demo once again:

And remember that you’re welcome to download the complete code for this demo on GitHub. If you’d like to see this working on a production site, check out my Shop Ireland project. Lazy loading is used on the homepage, category pages and search pages to help speed things up. I hope you find it useful for your own Svelte projects!