Simple Svelte Responsive Image Gallery
✨ Simple Svelte Responsive Image Gallery: Introduction
We look at a simple Svelte responsive image gallery in this post. By simple I mean to say the functionality is simple. Despite that it lets us explore a couple of Svelte and Vite features which are a little more advanced. In particular we look at glob importing where we can import, for example all files matching a certain pattern into SvelteKit JavaScript code. We also use Svelte dimension bindings to make sure all images from our gallery — tall and wide — look good, maintaining aspect ratio as the window size changes. As well as being responsive the images will be generated in Next-Gen formats. Finally we add an optimisation which should help with the Core Web Vitals Largest Contentful Paint metric. I should also mention we add lazy loading as another Core Web Vitals optimisation.
Generally, I prefer to roll my own components whenever possible, rather than leaning on libraries so really enjoyed putting this tutorial together. If you are looking for a simple scrolling gallery, supporting modern image formats that is responsive, this should do the trick. Even if you are looking for a fully-featured light box, you will probably find parts here which you can recycle for use with your own code or library.
⚙️ Getting Started
There's a bit to get through so let's get going! I have used a script to generate image data automatically to speed things up, so you will need to download those image data files as well as the images themselves in a moment. First though let's spin up a new skeleton project:
pnpm init svelte@next sveltekit-simple-image-gallery && cd $_
pnpm install
pnpm install @fontsource/inter @rodneylab/sveltekit-components svelte-feather-icons vanilla-lazyload vite-imagetools
pnpm run dev
From the options, choose Skeleton project, Use TypeScript:? No, Add ESLint...? Yes and Add Prettier...? Yes. As well as set up Svelte, we have installed a font and a Svelte component library to help with generating responsive image boiler plate. Together with those two packages, we have some icons for the next / previous buttons to move between images. Finally there's a couple of packages to help with lazy loading and Next-Gen image generation and caching.
As an extra bit of setup, update svelte.config.js
for use with vite-imagetools
:
import { imagetools } from 'vite-imagetools';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// hydrate the <div id="svelte"> element in src/app.html
target: '#svelte',
vite: {
plugins: [imagetools({ force: true })],
},
},
};
export default config;
File Download
Lastly create a src/lib/assets/
folder and download the six images from that location in the Git repo. Finally create src/lib/generated
and repeat, copying the JavaScript files from the equivalent folder on the GitHub repo. Typically you would want to generate these files in a script, updating for required images formats, widths and pixel densities, though I have done this already to save time. You can take a look at the script which generates this data including low resolution placeholders in the repo.
🔨 Server Route
Next we will create a server route. This file will look for the JavaScript image data files we just downloaded and generate a single array image data (spanning all images). Create the file at src/routes/index.json.js
and add the following content:
export async function get() {
try {
const images = await import.meta.glob('../lib/generated/*.js');
const imageDataPromises = Object.keys(images).map((path) => images[path]());
const imageData = await Promise.all(imageDataPromises);
const imageDataArray = imageData.map((element) => element.default);
return {
body: JSON.stringify({ data: imageDataArray }),
};
} catch (error) {
console.error('Error: ', error);
return {
status: 500,
error: `Error in index.json data retrieval: ${error}`,
};
}
}
There are one or two interesting things in here. In line 3
, we are importing all of the JavaScript files in the lib/generated
folder. To do this, we use a Vite Glob Import. Essentially, Vite expands this to an object:
const images = {
'../lib/generated/image-1.js': () => import('../lib/generated/image-1.js'),
'../lib/generated/image-2.js': () => import('../lib/generated/image-2.js'),
'../lib/generated/image-3.js': () => import('../lib/generated/image-3.js'),
'../lib/generated/image-4.js': () => import('../lib/generated/image-4.js'),
'../lib/generated/image-5.js': () => import('../lib/generated/image-5.js'),
'../lib/generated/image-6.js': () => import('../lib/generated/image-6.js'),
}
Each of the members of the object is a key-value pair, with the key being the path for one of the files in our folder. The value in each case is the import function, so to complete the import, we need to call the function on each field. We do that in line 4
, generating a promise for each file we import and mapping all the promises to an array.
Over the following lines, we extract the default export from each of the files using the Promises API. If this is your first time using async/await
, you might find the explanation in the post on the SvelteKit Image plugin useful.
Our endpoint generates an array of image data which we will use next on the home page.
🏠 Home Page Svelte
Next, we will replace the code in src/routes/index.svelte
with the following:
<script context="module">
export const load = async ({ fetch }) => {
try {
const response = await fetch('/index.json', {
method: 'GET',
credentials: 'same-origin',
});
return {
props: { ...(await response.json()) },
};
} catch (error) {
console.error(error);
}
};
</script>
<script>
import { browser } from '$app/env';
import RibbonGallery from '$lib/components/RibbonGallery.svelte';
import '@fontsource/inter';
import { onMount } from 'svelte';
import lazyload from 'vanilla-lazyload';
export let data;
onMount(() => {
if (browser) {
document.lazyloadInstance = new lazyload();
}
});
// import image data for caching images
(async () => {
await import.meta.glob('../lib/generated/*.js');
})();
</script>
<svelte:head>
<title>Basic Responsive Ribbon Gallery</title>
<html lang="en-GB" />
<meta
name="description"
content="Demo site for basic responsive image library with a ribbon layout"
/>
</svelte:head>
<div class="container">
<header class="header"><h1>Basic Responsive Ribbon Gallery</h1></header>
<main><RibbonGallery {data} /></main>
</div>
<style>
:global(html) {
font-family: 'Inter';
background: #006e90;
min-height: 100vh;
}
:global(body) {
margin: 0;
}
.container {
max-width: 1280px;
margin: 0 auto;
}
.header {
background: #01200f;
color: #eef5db;
font-weight: 900;
padding: 1rem 1.5rem;
}
</style>
In line 1
–15
we have a standard SvelteKit load function in which we get the image data array from our endpoint.
The onMount
function is called when our home page is created. We initialise our lazyload at this point. You can see more on this in the post on Lazy loading iframes in SvelteKit.
Lines 32
–35
probably seem pointless as we do not use the result anywhere. In these lines, we are importing the files we use in the endpoint to generate the image data array. In fact we only do this import here to ensure the images are cached. You might find you can omit this code running in dev mode, but switch to build and have no images!
In line 49
we add our image gallery component to the DOM. Let's add the code for this and a couple of ancillary components to our project next.
🧩 Simple Svelte Responsive Image Gallery Components
We will use feather icons for our forward and previous user interface buttons. Create a folder at src/lib/components
then add NextIcon.svelte
and PreviousIcon.svelte
to the folder, and paste in this code:
<script>
import { SkipForwardIcon } from 'svelte-feather-icons';
</script>
<SkipForwardIcon size="32" />
<script>
import { SkipBackIcon } from 'svelte-feather-icons';
</script>
<SkipBackIcon size="32" />
We're almost done now! Next step is to add the final missing piece; the gallery component.
🖼 Ribbon Gallery Component
The image gallery will have a few features to make the pictures look their best. This includes preserving the image aspect ratio when the window is resized and keeping all images the same height as we scale. As well as that we want to ensure that for a small-screened device, the widest image in the gallery can be displayed, without panning. For this to happen, we need to work out which is the widest image and use its aspect ratio to set the height for all of the images. To get all of this right, we will use Svelte dimension binding. There is a little maths (math) involved, but it's not too complex.
Lets start putting the image component together. Create a src/lib/components/RibbonGallery.svelte
file and paste in the following code:
<script lang>
import { browser } from '$app/env';
import NextIcon from '$lib/components/NextIcon.svelte';
import PreviousIcon from '$lib/components/PreviousIcon.svelte';
import { Image } from '@rodneylab/sveltekit-components';
import { afterUpdate, onMount } from 'svelte';
export let data;
let containerHeight;
let containerWidth;
let maxAspectRatio = 1.0;
$: aspectRatios = data.map((element) => {
const { width, height } = element;
const aspectRatio = width / height;
if (aspectRatio > maxAspectRatio) {
maxAspectRatio = aspectRatio;
}
return aspectRatio;
});
$: height = 512;
$: calculateHeight;
function calculateHeight() {
if (containerHeight && containerWidth) {
const maxHeight = containerHeight - 59;
height =
containerWidth / maxHeight < maxAspectRatio ? containerWidth / maxAspectRatio : maxHeight;
}
}
onMount(() => {
if (browser && document.lazyloadInstance) {
document.lazyloadInstance.update();
calculateHeight();
}
});
afterUpdate(() => {
calculateHeight();
});
$: widths = [...aspectRatios.map((element) => parseFloat((element * height).toFixed(2)))];
$: sizes = [...widths.map((element) => `${element}px`)];
$: currentIndex = 0;
$: imageTitle = data[currentIndex].title;
Here in lines 10
& 11
we create variables which we need to hold the measurements for our container height and width. Then at lines 15
to 22
we have a utility function to work out the image with highest aspect ratio. Aspect ratio is width divided by height, so the widest image has the largest aspect ratio.
Image Height
Next in line 24
–32
we work out what height our images should have. To start the ball rolling, we set an initial height of 512px
. In a moment we will see that we bind containerHeight
and containerWidth
to the actual DOM object dimensions. Because of that, we need to wait for the DOM to be ready, before we have a value (hence the guard in line 27
). The element we measure will have the images on top and some controls to shuffle through the images below. In between there might be some space, depending on the browser window height. We always want to allow some space for the controls below so in determining in the height for our images, we subtract the height of the controls (59px
) in line 28
.
Moving on to the code in line 30
. Let's call the difference between the height of our measured element and the height of the controls the maximum height. Generally, we want the images to be as big as possible, so try to set their height to be equal to the maximum height. In line 30
, we look at the widest image and if we find it is just too wide to display at maximum height (without having to pan), we reduced the height of all the images. The height we choose is back calculated from the width of our element and the aspect ratio of this widest image.
So, this block is just working out when we need to reduce the image height, and what that reduced height should be. We call the calculateHeight
function when the component first mounts (line 37
) and then again when it updates (line 42
), to keep the height good.
Previous, Next Image Logic
Let's add some logic to move between images next, by pasting this code at the bottom of the same file:
const imageCount = data.length;
function advanceIndex() {
currentIndex = (currentIndex + 1) % imageCount;
}
function regressIndex() {
currentIndex = (currentIndex + imageCount - 1) % imageCount;
}
function prefersReducedMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function scrollToNextImage() {
advanceIndex();
if (prefersReducedMotion()) {
document
.getElementById(`image-${currentIndex + 1}`)
.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' });
} else {
document
.getElementById(`image-${currentIndex + 1}`)
.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'start' });
}
}
function scrollToPreviousImage() {
regressIndex();
if (prefersReducedMotion()) {
document
.getElementById(`image-${currentIndex + 1}`)
.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' });
} else {
document
.getElementById(`image-${currentIndex + 1}`)
.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'start' });
}
}
</script>
In lines 53
and 57
we are using the modulus operation (%
) so we can loop around to the first or last image when we get to the last image. I really love the way Svelte handles animation and makes it easy to add some polish to image transitions in image galleries. Here though in-built HTML functionality is pretty good and we will rely on that. In particular we are using element.scrollIntoView()
.
For this API to work, we add a unique id to each of our images and scroll to the id
of whichever image we choose. The rest just works! If you have a lot of images though and scroll from the first to last, scrolling can be quite quick when smooth scrolling in switched on! If the user prefers reduced motion, we revert to auto
which scrolls a little slower. That's all the JavaScript so let's add some HTML markup now.
Svelte Dimension Binding
Paste this svelte code at the bottom of the same file:
<div class="container" bind:clientWidth={containerWidth} bind:clientHeight={containerHeight}>
<div class="ribbon">
<section class="images">
{#each data as { alt, src, sources, placeholder }, index}
<div id={`image-${index + 1}`}>
<Image
{alt}
width={widths[index]}
{height}
{src}
{sources}
{placeholder}
sizes={sizes[index]}
loading={index === 0 ? 'eager' : 'lazy'}
importance={index === 0 ? 'high' : 'auto'}
maxWidth="1280px"
/>
</div>
{/each}
</section>
</div>
<section class="info">
<div class="controls">
<span class="prev-next-button">
<button
on:click={() => {
scrollToPreviousImage();
}}><PreviousIcon /><span class="screen-reader-text">previous image</span></button
></span
>
<p>{currentIndex + 1} of {imageCount}</p>
<span class="prev-next-button">
<button
on:click={() => {
scrollToNextImage();
}}><NextIcon /><span class="screen-reader-text">next image</span></button
></span
>
</div>
<div class="title-text"><h1>{imageTitle}</h1></div>
</section>
</div>
We saw previously we had dimensions of the container element in the JavaScript for this component. In line 91
you see how we bind the Svelte measured dimension to the JavaScript variable. Once more Svelte makes something that could very complicated very simple. Be careful not to use this where it is not necessary as it comes with a performance hit. Learn more about Svelte dimension bindings in Svelte docs.
Image Load Optimisation
We have a few image loading optimisations here to help improve Core Web Vitals together with the user experience as well as SEO of your app. We already mentioned images are lazy loaded. This means the user's browser initially only loads the images that are in view. The others are only loaded when the user scrolls over. The vanilla-lazyload
plugin helps with this. On top we give a hint to the browser in line 104
to load images lazily. We want the user to see something when the page first load so the first image loads eagerly.
Next, we add low resolution placeholders. Together with width and height data, which we supply, this lets the browser know how much space to reserve for the images, reducing cumulative layout shift. Because we want the image to scale to the browser width and maintain aspect ratio, there is some potential for CLS for any elements below the images in the DOM. Bear this is mind if you use this code for other projects.
Finally we set importance
to high for the first image in line 105
. This is another hint to the browser to give the user something to see quicker and should help to improve the First Contentful Paint metric.
As an aside, in line 95
we add a unique id to each image to help with the scroll into view function we looked at earlier.
Style
The last part is to add style. Unlike some other tutorials on this site, styling is needed here for the gallery to work as expected. This is mostly because we set heights on some elements. To finish off paste this CSS code at the end of the RibonGallery.svelte
file:
<style>
.container {
display: flex;
flex-direction: column;
height: calc(100vh - 7.12rem);
max-width: 1280px;
width: 100%;
margin: 0 auto;
}
.ribbon {
display: flex;
width: 100%;
}
.images {
display: flex;
overflow: hidden;
}
.info {
display: flex;
align-items: center;
margin: auto 1rem 1rem;
color: #eef5db;
}
.info button {
border-style: none;
background: transparent;
}
.info p,
.info h1 {
margin-top: 0;
margin-bottom: 0;
}
.info h1 {
font-size: 1.563rem;
}
.controls {
display: flex;
align-items: center;
padding-right: 0.5rem;
width: max-content;
}
.controls .prev-next-button {
display: flex;
vertical-align: middle;
color: #eef5db;
}
.controls button {
color: #eef5db;
padding: 0.75rem 0.5rem;
cursor: pointer;
}
.title-text {
padding-right: auto;
}
.screen-reader-text {
border: 0;
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
height: 1px;
margin: -1px;
width: 1px;
overflow: hidden;
position: absolute !important;
word-wrap: normal !important;
}
</style>
That's all the code and everything should work now. Give it a try!
💯 Simple Svelte Responsive Image Gallery: Testing
That's it, mission complete (apart from testing). First we want to make sure the controls work for moving between images. Make sure you can bring all the images into view using the previous and next buttons. Then try resizing the browser window. All images should maintain aspect ratio as you make the window larger or smaller.
The final test is to make the browser window tall and narrow and scroll to the fourth image. It should span the width of the window. You should not need to pan to see the entire image.
If that's all work let's recap and look at some extensions.
🙌🏽 Simple Svelte Responsive Image Gallery: What we Learned
In this post we saw:
how you can bind the dimensions of an element to a JavaScript variable in Svelte,
a way to import all the files in a particular using Vite glob imports,
how to optimise images for Core Web Vitals and better user experience.
I do hope there is at least one thing in this article which you can use in your work or a side project. As an extension you might consider infinitely looping the images, so you don't get the disjoint scroll when you reach the last image. You would have to anticipate reaching the last image and tack the first image onto the end of the array (and something similar for scrolling backwards past the first image).
You can see the full code for this using Simple Svelte Responsive Image Gallery tutorial on the Rodney Lab Git Hub repo. As always get in touch with feedback if I have missed a trick somewhere!
🙏🏽 Simple Svelte Responsive Image Gallery: Feedback
Have you found the post useful? Do you have your own methods for solving this problem? Let me know your solution. Would you like to see posts on another topic instead? Get in touch with ideas for new posts. Also if you like my writing style, get in touch if I can write some posts for your company site on a consultancy basis. Read on to find ways to get in touch, further below. If you want to support posts similar to this one and can spare a few dollars, euros or pounds, please consider supporting me through Buy me a Coffee.
Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via @askRodney on Twitter and also askRodney on Telegram. Also, see further ways to get in touch with Rodney Lab. I post regularly on SvelteKit as well as other topics. Also subscribe to the newsletter to keep up-to-date with our latest projects.