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 yourpackage.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.
<script>
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 + '%'
</script>
<style>
.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%;
}
</style>
<div class="wrapper">
<div class="aspect-ratio-holder" style="--pb:{padding_bottom_percentage}" />
<img
class="lazyload"
{src}
srcset={placeholder}
data-srcset={srcset}
{sizes}
{alt}
/>
<noscript>
<img {src} {srcset} {sizes} {alt} />
</noscript>
</div>
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 informationasset->
: 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": "https://cdn.sanity.io/images/kxkzwcge/production/26310230bf276b6456ba36e2e232a9c7ae154b8e-1350x900.png"
},
"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')
.join(',')
// Return the object shape required by Image.svelte (minus a couple)
return {
aspectRatio,
placeholder,
src,
srcset,
}
}
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
.fetch(GROQ_QUERY)
.catch((err) => this.error(500, err))
// Transform the image data
data.headerImage = generateImage(data.headerImage)
return { data }
}
</script>
<script>
import Image from '../components/Image.svelte'
export let data
</script>
<div style="min-height: 2000px" /> <!-- scroll down to test lazy-loading -->
<Image {...data.headerImage} />
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 aftersizes
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’: