loading...
Cover image for Svelte Lazy Image

Svelte Lazy Image

gobeli profile image Etienne Updated on ・3 min read

I recently decided to lazy load images on a Sapper powered website to optimize the initial loading time. I did this by using a placeholder URL and as soon as the image is visible to the user, replacing this placeholder URL with the real deal. Similar to the gatsby-image-approach (obviously not as sophisticated). Let me take you through the process :)

1. Detect when the image is visible

The first step is to make sure we can detect when an element (in our case an img) is first visible to the user. This can be achieved with the Intersection Observer API. This API is really useful to detect intersections between an element and its ancestor or in our case the top-level viewport.

To prevent instantiating a new IntersectionObserver for each image we will write an intersection service. To do that, let's define a variable in which the observer will be saved and a Map which we will use to keep track of all the elements in the observer:

let observer
const elements = new Map()

Next we make sure this same IntersectionObserver is always used:

const getObserver = () => {
  if (!observer) {
    observer = initObserver()
  }
  return observer
}

The initObserver function is referenced in the previous snippet, but not yet implemented, let's do that:

const initObserver = () => {
  return new IntersectionObserver((entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const lazy = entry.target
        observer.unobserve(lazy)
        if (elements.has(lazy)) {
          elements.get(lazy)()
          elements.delete(lazy)
        }
      }
    })
  })
}

The new observer watches intersections with each of it's targets which we will add soon. As soon as an intersection is detected (entry.isIntersecting) we don't need to observe the element anymore, since the real image URL is loaded. Then if we find the element in our map tracking the elements (which we normally should) we call the function saved in the map and delete the entry, since we don't use it anymore.

To observe an element we use the only function exported from this service: observe:

export const observe = (element) => {
  const obs = getObserver()
  return new Promise((resolve) => {
    elements.set(element, resolve)
    obs.observe(element)
  })
}

The observe function returns a promise which is resolved as soon as the element is intersecting with the viewport (is visible).

2. Svelte Component

The next step is to implement a svelte component using this service to replace a placeholder URL with the real source. This is fairly simple:

<script>
  import { onMount } from 'svelte'
  import { observe } from './intersection.service'

  let image, source

  onMount(async () => {
    source = placeholder
    await observe(image)
    source = src
  })

  export let src, placeholder, alt
</script>

<img src={source} {alt} bind:this={image} {...$$restProps} />

On mounting we set the image source to the placeholder and as soon as the image is visible we swap the sources. The ...$$restProps is there to make sure, things like styles and other attributes get applied to the img.

3. Use it

The final usage is pretty simple:

<Image
    alt="clouds"
    style="width: 100%"
    src="https://images.unsplash.com/photo-1587476821668-7e1391103e49?w=1600"
    placeholder="https://images.unsplash.com/photo-1587476821668-7e1391103e49?w=16" />

4. Next steps

To make sure this can be used in all browsers you are supporting you might need to add an intersection observer polyfill
Furthermore we could optimize this approach by automatically compress images during bundling and using them as the placeholders.

Discussion

pic
Editor guide