Svelte + Sanity responsive, lazy-loaded, jank-free images

27 May 2020

Slightly outdated as a small part of this guide is based on Sapper instead of SvelteKit.

In this post we’ll attempt to create our very own gatsby-image on our Svelte + Sanity project combo. It won’t be as feature complete as Gatsby’s, but each step will be < 50 lines of code that will hopefully be easy for you to tweak as you need.

If you haven’t known about the basics of a responsive, lazy-loaded, jank-free image — or if you need a refresher — I highly recommend you first read about it from my previous post.

What you need

This post won’t cover setting up Svelte and Sanity, as it’ll make the post longer than it should be. You need:

  • A working Svelte setup
  • A Sanity project with image(s)
  • Familiarity with GROQ query language (it’s possible to do this with GraphQL, you just need to know the right fields to query)
  • lazysizes, @sanity/client, and @sanity/image-url installed in your package.json

In this how-to, I’ve created a document named homepage, and in it an image field named headerImage (with options.hotspot set to true).

The image component

lazysizes is used here because it’s a well-known package that helps with lazy-loading as well as automating sizes attribute.

	import 'lazysizes'

	export let aspectRatio
	export let placeholder
	export let src
	export let srcset
	export let alt
	export let sizes = 'auto' // 'auto' only works when using `lazysizes`

	let padding_bottom_percentage = 100 / aspectRatio + '%'

	.wrapper {
		position: relative;
		overflow: hidden;

	.aspect-ratio-holder {
		--pb: 100%;
		padding-bottom: var(--pb);

	img {
		position: absolute;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;

<div class="wrapper">
	<div class="aspect-ratio-holder" style="--pb:{padding_bottom_percentage}" />


		<img {src} {srcset} {sizes} {alt} />

srcset is first set as placeholder (LQIP), which will then be replaced by the value of data-srcset when the image intersects the screen.

Setting up Sanity Client and Image URL Builder

import sanityClient from '@sanity/client'

const client = sanityClient({
	projectId: 'YOUR_PROJECT_ID',
	dataset: 'YOUR_DATASET', // likely 'production'
	useCdn: true,

export default client
import imageUrlBuilder from '@sanity/image-url'
import myConfiguredSanityClient from './sanityClient'

const builder = imageUrlBuilder(myConfiguredSanityClient)

function urlFor(source) {
	return builder.image(source)

export default urlFor

Querying the right data

Within your image, you should project:

  • ...: everything, which will include hotspot and crop information
  • asset->: referenced asset, which will include aspectRatio and lqip
const GROQ_QUERY = `
	*[_id == 'homePage'][0]{
		headerImage {..., asset->}

Example of returned data:

	"headerImage": {
		"_type": "image",
		"asset": {
			"_createdAt": "2020-05-21T13:27:05Z",
			"_id": "image-26310230bf276b6456ba36e2e232a9c7ae154b8e-1350x900-png",
			"_rev": "Zn2HGQLrJfc76FHHwgNU8d",
			"_type": "sanity.imageAsset",
			"_updatedAt": "2020-05-21T13:27:05Z",
			"assetId": "26310230bf276b6456ba36e2e232a9c7ae154b8e",
			"extension": "png",
			"metadata": {
				"_type": "sanity.imageMetadata",
				"dimensions": {
					"_type": "sanity.imageDimensions",
					"aspectRatio": 1.5,
					"height": 900,
					"width": 1350
				"hasAlpha": true,
				"isOpaque": true,
				"lqip": "data:image/png;base64,iVBORw0KGgoAAAANSUh…",
				"palette": {
					// …
			"mimeType": "image/png",
			"originalFilename": "image.png",
			"path": "images/kxkzwcge/production/26310230bf276b6456ba36e2e232a9c7ae154b8e-1350x900.png",
			"sha1hash": "26310230bf276b6456ba36e2e232a9c7ae154b8e",
			"size": 1136744,
			"uploadId": "N7xWdKSTFDVipS5ygiBI56DEYqRyxkul",
			"url": ""
		"crop": {
			"_type": "sanity.imageCrop",
			"bottom": 0,
			"left": 0,
			"right": 0.4555984555984558,
			"top": 0.22029751759481486
		"hotspot": {
			"_type": "sanity.imageHotspot",
			"height": 0.7797024824051851,
			"width": 0.5444015444015442,
			"x": 0.2722007722007721,
			"y": 0.6101487587974075

The returned data can be large, especially if you’re working with multiple images. And not every field is required. To work around this, you can either:

  • fetch and transform server-side, returning only the transformed (generated image) data to the client, or
  • cherry-pick the required data (instead of the all-encompassing {..., asset->}).

Next, you’re going to pass the headerImage through a transformation function.

Transforming data to Image component’s props

There are inline comments to help you make sense of what this function does. In essence, it transforms the raw response above to what’s required by our Image component — cropping taken into account.

import urlFor from './sanityImageUrlBuilder'

function generateImage(image) {
	// aspectRatio (to prevent jank)
	let aspectRatio
	if (image.crop) {
		// priority: set aspectRatio equal to content editor’s crop settings
		aspectRatio = getCropFactor(image.crop) * image.asset.metadata.dimensions.aspectRatio
	} else {
		// else, just set aspectRatio equal to the original image’s
		aspectRatio = image.asset.metadata.dimensions.aspectRatio

	// LQIP
	const placeholder = image.asset.metadata.lqip

	// src
	const src = urlFor(image).url()

	// srcset

	// Change these widths as you need
	const widthsPreset = [640, 768, 1024, 1366, 1600, 1920, 2560]

	const srcset = widthsPreset
		// Make srcset url for each of the above widths
		.map((w) => urlFor(image).width(w).url() + ' ' + w + 'w')

	// Return the object shape required by Image.svelte (minus a couple)
	return {

function getCropFactor({ top, bottom, left, right }) {
	const xFactor = 1 - (left + right)
	const yFactor = 1 - (top + bottom)

	return xFactor / yFactor

export default generateImage

Tying it all together

With all the boilerplate code done, we’re ready to query, transform, and display the image. The following example is done in Sapper.

<script context="module">
	import client from '../sanityClient'
	import urlFor from '../sanityImageUrlBuilder'
	import generateImage from '../generateImage'

	export async function preload({ params }) {
		const GROQ_QUERY = `
			*[_id == 'homepage'][0]{
				headerImage {..., asset->}

		const data = await client
			.catch((err) => this.error(500, err))

		// Transform the image data
		data.headerImage = generateImage(data.headerImage)

		return { data }

	import Image from '../components/Image.svelte'
	export let data

<div style="min-height: 2000px" /> <!-- scroll down to test lazy-loading -->

<Image {} />

And that’s it. You should have an image that starts with LQIP and swaps to the actual image when scrolled to.

Possible improvements

Smooth transition from LQIP to actual image

gatsby-image for reference does this well. It does a smooth fade transition from LQIP to the actual image.

Replacing lazysizes

There are a few libraries, some of which are smaller in size than lazysizes.

But, lazysizes also comes with automating sizes attribute, without it you can use something like RespImageLint to make the process slightly less of a chore.

Be careful when automating on your own, e.g. using bind:clientWidth on the parent container. It might cause images to download twice: once after page load, and another after sizes has been set.

Use the platform™

If you’re targeting only bleeding edge browsers, you may simplify the Image component further by using loading="lazy" attribute for lazy-loading, and width + height attributes for aspect-ratio.

You may refer to these resources:

Or wait for my next post, which will be exactly this. As of the time of writing, this technique is supported by 64% of browsers globally according to ‘Can I use’: