Svelte CSS Image Slider: with Bouncy Overscroll
Tags: webdev, svelte, css, javascript
Published: 2022-10-25 09:19:30

---
title: "Svelte CSS Image Slider: with Bouncy Overscroll"
published: "true"
description: "Svelte CSS image slider: how to create a CSS :hover preview image gallery with mobile bouncing overscroll using future CSS and SvelteKit."
tags: "webdev, svelte, css, javascript"
canonical_url: "https://rodneylab.com/svelte-css-image-slider/"
cover_image: "https://dev-to-uploads.s3.amazonaws.com/uploads/articles/33kivxw1nboi37xltqgr.png"
---

## 🛸 Svelte CSS Image Slider: What is CSS :hover?

First things first, what is a Svelte CSS Image Slider? The slider part is just an image gallery with a strip of thumbnails. Click a thumbnail and you see a large preview of the image you selected.  The hover part is making use of a some future CSS to create an effect similar to a desktop computer toolbar. There when you hover the mouse pointer over an app icon, it shifts up a touch and simultaneously grows.

![Svelte CSS Image Slider: Screen capture shows a large image preview above of a Mayan temple. Below is a row of 5 thumbnail images.  The mouse hovers over the second which is largest and lifted higher above the others. Moving away from this larger image, in either direction, the thumbnails become progressively smaller.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/paug436cwbm7jjc5v9mi.png)

We use some code by <a aria-label="Open Jhey Thompkins Twitter profile" href="https://twitter.com/jh3yy">Jhey Thompkins</a> here to produce that same effect when we hover over an image preview.  Jhey added sprinkles; using the future CSS `:has` selector also to make the thumbnails either side of the focussed one shift and grow, albeit not as much.  Using just CSS to do this makes the transitions look more smooth and natural than using JavaScript.  The only drawback is that `:has` is not supported in Firefox at the time of writing (although Chrome and Safari do support it).  Despite this, we can still grow and shift the focussed image in Firefox.

## 🏓 Svelte CSS Image Slider: What is a Bouncing Slider?

The overscroll bouncing slider, is a little bonus for mobile device users.  The hover effect doesn&rsquo;t look great on smaller screens. For that reason, we replace it with a slider. So mobile visitors don&rsquo;t miss out on the future CSS magic, we add the bounce effect to the scroller.  This is similar to an effect you might see on an iPhone, if you try to scroll past the start or end.  The scroll bounces back to the first or last image.  We use some code <a aria-label="Open Adam Argyle's Twitter profile" href="https://twitter.com/argyleink">Adam Argyle</a> demoed at the CSS Day conference to get this effect, again only with CSS for a natural feel across devices.

## 🧱 Svelte CSS Image Slider: What are we Building?

![Svelte CSS Image Slider: Screen capture shows Page Speed Insights desktop scores.  Despite a very large image being displayed in the preview, the performance is perfect at 100 with 0.3s time to interactive, speed index and first contentful paint. Total blocking time is zero and cumulative layout shift is just 0.001.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5utz64v3ynzi9voz8pgx.png)

We will build the CSS image slider we just described with a few extras.  A follower asked me to write a tutorial on creating a node SvelteKit app which we can run on Ubuntu or another Linux distribution. Taking that into account, I thought this would be a great opportunity to be able to use the Sharp image processing package on the server.  Sharp requires a NodeJS environment to run so is not well-suited to serverless deploys. For speed across all devices we want to serve responsive image sets.  It can be painful to produce the inputs manually as static assets for all devices.

Sharp comes to the rescue here. We run it on a Svelte server endpoint which resizes images to the required size on the fly.  We will see later, that we can add a public cache directive so Cloudflare will be able to cache the generated images for us. This helps serve images efficiently, without creating static assets in all the various formats and sizes.

You can code along or check open the site in StackBlitz to have a play (GitHub repo link further down).

## ⚙️ Following Along? Get Started Here&hellip;

Spin up a fresh SvelteKit app:

```shell
pnpm create svelte@next svelte-css-image-slider && cd $_
```

If this is your first time using SvelteKit, <a href="https://rodneylab.com/how-to-create-svelte-app/">learn how to create a Svelte app</a>.

Then, install these packages:

```shell
pnpm add -D @fontsource/figtree @sveltejs/adapter-node \
	@types/postcss-preset-env @types/sharp autoprefixer \
	postcss-env-function postcss-load-config \
	postcss-preset-env
pnpm add --save-peer postcss
pnpm add sharp
```

Be sure to install sharp in `dependencies` and not `serverDependencies`.  See this <a href="https://github.com/sveltejs/kit/issues/6440">GitHub issue for explanation of why server dependencies should not be added as `devDependencies`</a> when when you use the Node adapter.

### Svelte CSS Image Slider: Future CSS Setup

To use some of the future CSS, we can set up PostCSS. Create `postcss.config.cjs` in the project rot folder with the content below. Props to <a aria-label="See config this is based on" href="https://joyofcode.xyz/">Matia for post on this config</a>.

```javascript
const autoprefixer = require('autoprefixer');
const postcssPresetEnv = require('postcss-preset-env');
 
const config = {
	plugins: [
		autoprefixer(),
		postcssPresetEnv({
			stage: 3,
			features: {
				'nesting-rules': true,
				'custom-media-queries': true,
				'media-query-ranges': true
			}
		})
	]
};
 
module.exports = config;
```

Then, update the Svelte Config file (`svelte.config.js`):

```javascript
import adapter from '@sveltejs/adapter-node';
import preprocess from 'svelte-preprocess';
import cspDirectives from './csp-directives.mjs';
 
/** @type {import('@sveltejs/kit').Config} */
const config = {
	// Consult https://github.com/sveltejs/svelte-preprocess
	// for more information about preprocessors
	preprocess: preprocess({ postcss: true }),
 
	kit: {
		adapter: adapter({ precompress: true }),
		csp: {
			mode: 'hash',
			directives: cspDirectives
		}
	}
};
 
export default config;
```

### Images Setup

Then, add create a `src/lib/data` folder and add the JSON image meta data below to a `images.json` file in the new directory.

```json
{
	"machu-picchu": {
		"title": "Machu Picchu, Perú",
		"alt": "Intricately carved Inca ruins at Machu Picchu balanced atop a mountain top backed by Waynu Picchu and other Andes mountain peaks",
		"source": "https://unsplash.com/photos/rFESpq5MMTg",
		"credit": { "name": "Eddie Kiszka", "profile": "https://unsplash.com/@eddiekiszka" }
	},
	"palenque": {
		"title": "Palenque, México",
		"alt": "Palenque Mayan temple dwarfs visitors amid green grass strong Méxican sunlight",
		"source": "https://unsplash.com/photos/eLqm1qHJJgI",
		"credit": { "name": "Crisoforo Gaspar Hernandez", "profile": "https://unsplash.com/@mitogh" }
	},
	"peten": {
		"title": "Petén, Guatemala",
		"alt": "Imense Mayan ruins with steep steps to left and behind border an open area with rustic buildings with straw roofs",
		"source": "https://unsplash.com/photos/eoZTnFtusDs",
		"credit": { "name": "Jimmy Baum", "profile": "https://unsplash.com/@amongthestones" }
	},
	"petra": {
		"title": "Petra, Jordan",
		"alt": "Huge edifice carved into rock face towers above two people dressed in medieval armour as dusk approaches",
		"source": "https://unsplash.com/photos/eoZTnFtusDs",
		"credit": { "name": "Brian Kairuz", "profile": "https://unsplash.com/@briankairuz" }
	},
	"stonehenge": {
		"title": "StoneHenge, United Kingdom",
		"alt": "Desserted Stonehenge amid, with lush, green grass in the foreground and bright, albeit overcast skies",
		"source": "https://unsplash.com/photos/oQ9pECond48",
		"credit": { "name": "K. Mitch Hodge", "profile": "https://unsplash.com/@kmitchhodge" }
	}
}
```

You can get the images themselves from the GitHub repo. Save them to the paths shown below (within a new `src/assets` folder). Here are the links:

- <a aria-label="Download the Machu Picchu image" href="https://github.com/rodneylab/sveltekit-css-hover-image-slider/raw/main/src/assets/machu-picchu.jpg">`src/assets/machu-picchu.jpg`</a>,
- <a aria-label="Download the Machu Picchu image" href="https://github.com/rodneylab/sveltekit-css-hover-image-slider/raw/main/src/assets/palenque.jpg">`src/assets/palenque.jpg`</a>,
- <a aria-label="Download the Machu Picchu image" href="https://github.com/rodneylab/sveltekit-css-hover-image-slider/raw/main/src/assets/peten.jpg">`src/assets/peten.jpg`</a>,
- <a aria-label="Download the Machu Picchu image" href="https://github.com/rodneylab/sveltekit-css-hover-image-slider/raw/main/src/assets/petra.jpg">`src/assets/petra.jpg`</a>,
- <a aria-label="Download the Machu Picchu image" href="https://github.com/rodneylab/sveltekit-css-hover-image-slider/raw/main/src/assets/stonehenge.jpg">`src/assets/stonehenge`</a>.

Images are all from unsplash. See JSON file above for credits.

## 🍽 Layout: Self-hosting new Figtree Font

We&rsquo;re trying out the <a aria-label="Read about figtree on Erik Kennedy's site" href="https://www.erikdkennedy.com/projects/figtree.html">new, free Figtree Google sans serif font</a> by Erik Kennedy here, which we self-host.   Self-hosting helps with optimisation, resulting in one less origin for visitor browsers to connect to.  This brings <a aria-label="Open post on making fonts faster" href="https://sia.codes/posts/making-google-fonts-faster/#self-host-your-web-fonts-for-full-control">HTTP/2 multiplexing potential benefits</a> too.

Besides the new font there is nothing extraordinary in the layout file.  The site will have a page for each image with a URL like `https://example.com/machu-picchu`.  For that reason we create a `src/routes/[slug]` dynamic route folder. Create the `+layout.svelte` file in this new folder with the following content:

```svelte
<script lang="ts">
	import '@fontsource/figtree';
</script>
 
<slot />
 
<style lang="postcss">
	:global(*, *:after, *:before) {
		box-sizing: border-box;
	}
 
	:global(body) {
		margin: var(--spacing-0);
		min-height: 100vh;
		font-family: var(--font-family-body);
		background: var(--colour-brand);
 
		/* CREDIT: https://www.joshwcomeau.com/gradient-generator/ */
		background-image: linear-gradient(
			30deg,
			hsl(221deg 51% 16%) 0%,
			hsl(222deg 39% 20%) 38%,
			hsl(223deg 31% 23%) 50%,
			hsl(223deg 25% 27%) 58%,
			hsl(222deg 21% 31%) 64%,
			hsl(222deg 21% 31%) 69%,
			hsl(223deg 25% 27%) 73%,
			hsl(223deg 31% 23%) 78%,
			hsl(222deg 39% 20%) 84%,
			hsl(221deg 51% 16%) 100%
		);
	}
 
	:root {
		--lerp-0: 1;
		--lerp-1: 0.5625;
		--lerp-2: 0.25;
		--lerp-3: 0.0625;
		--lerp-4: 0;
 
		--spacing-px: 1px;
		--spacing-px-2: 2px;
		--spacing-0: 0;
		--spacing-1: 0.25rem;
		--spacing-2: 0.5rem;
		--spacing-4: 1rem;
		--spacing-5: 1.25rem;
		--spacing-6: 1.5rem;
		--spacing-12: 3rem;
		--spacing-24: 6rem;
		--spacing-28: 7rem;
		--spacing-36: 9rem;
		--spacing-outer: var(--spacing-4);
		--max-width-container-sm: 42rem; /* 672px */
		--max-width-container-md: 48rem; /* 768px */
		--max-width-container-lg: 64rem; /* 1024px */
		--max-width-container-xl: 80rem; /* 1280px */
		--max-width-full: 100%;
 
		--_max-width-wrapper: var(--max-width-container-sm);
 
		@media (width > 64rem) {
			--_max-width-wrapper: var(--max-width-container-md);
		}
 
		@media (width > 80rem) {
			--_max-width-wrapper: var(--max-width-container-lg);
		}
 
		/* font sizes */
		--font-size-3: 1.266rem;
		--font-size-4: 1.424rem;
		--font-size-5: 2.441rem;
 
		/* font weights */
		--font-weight-bold: bold;
 
		/* font families */
		--font-family-body: Figtree;
 
		--colour-dark: hsl(0deg 0% 0%); /* black */
		--colour-light: hsl(0deg 0% 90%); /* mercury */
		--colour-theme: hsl(221deg 51% 16%); /* big stone */
		--colour-alt: hsl(30deg 1% 47%); /* concord */
		--colour-brand: hsl(180deg 8% 45%); /* sirocco */
 
		/* CREDIT: https://www.joshwcomeau.com/shadow-palette/*/
		--shadow-color: 224deg 67% 6%;
		--shadow-elevation-medium: -1px 1px 1.6px hsl(var(--shadow-color) / 0.36),
			-3.3px 3.3px 5.3px -0.8px hsl(var(--shadow-color) / 0.36),
			-8.2px 8.2px 13px -1.7px hsl(var(--shadow-color) / 0.36),
			-20px 20px 31.8px -2.5px hsl(var(--shadow-color) / 0.36);
	}
</style>
```

## 📸 Image Generation Server Endpoint

Let&rsquo;s take a look server side next.  We mentioned briefly earlier that we will generate responsive images on the fly.  We will see later we can speed up page load but serving images appropriate to the user device. Although this is a fantastic performance optimisation, it can mean generating quite a few resized images of different sizes for each actual image we want to include. Consider the fact that we want to support devices with `340px` wide screens as well as show images up to `1024px` wide on desktops with larger displays.  On top to suport Retina and other high density displays, we also make images available in double width too as well as in WebP and AVIF next-gen formats. A lot of image to include statically 🥵! That&rsquo;s why we generate them.

### Cloudflare Early Hints

To speed things up, we will include a  `rel=preload` in the page header and include a public caching header. This should encourage <a aria-label="Read more about Cloud flare Early Hints" href="https://blog.cloudflare.com/early-hints/">Cloudflare to use 103 Early hints</a> as well as cache the images.  Although things will be slow for the first visitor as images get generated, subsequent visitors will the cached versions.  As a further optimisation, we will set the cache header to `immutable`.  This instruction tells browsers and caching servers that we never expect to update the images.  What is we do want to update them?  That&rsquo;s where the `hash` part of the file path comes in!  If we change the file, we can expect the hash to change too and the `immutable` directive on the old path will not matter.


```typescript
import { error } from '@sveltejs/kit';
import { createReadStream, statSync } from 'node:fs';
import type { Readable } from 'node:stream';
import { PassThrough } from 'node:stream';
import { join } from 'path';
import sharp from 'sharp';
import type { RequestHandler } from './$types';
  
async function metadata(
	source: string
): Promise<{ format?: string; width?: number; error?: string }> {
	try {
		const image = sharp(source);
		const { format, width } = await image.metadata();
		if (format) {
			return { format, width };
		}
	} catch (error: unknown) {
		console.error(`Error reading image metadata: ${source}: ${error}`);
	}
	return { error: `Error reading image metadata: ${source}` };
}
 
function resize(imageStream: Readable, width: number, format: string): ReturnType<RequestHandler> {
	let sharpTransforms;
	switch (format) {
		case 'jpeg':
			sharpTransforms = sharp().resize(width).jpeg({ mozjpeg: true });
			break;
		case 'avif':
			sharpTransforms = sharp().resize(width).avif({ quality: 80 });
			break;
		case 'webp':
			sharpTransforms = sharp().resize(width).webp({ quality: 80 });
			break;
		default:
			sharpTransforms = sharp();
	}
	const passthroughStream = new PassThrough();
	imageStream.pipe(sharpTransforms).pipe(passthroughStream);
 
	// @ts-expect-error respond with passthrough stream
	return new Response(passthroughStream as unknown);
}
  
// Based on: https://github.com/remix-run/examples/blob/main/image-resize/app/routes/assets/resize/%24.ts
export const GET: RequestHandler = async function get({ params, url, setHeaders }) {
	try {
		const { filename } = params;
 
		const srcPath = join('src/assets', filename);
		const fileStat = statSync(srcPath);
 
		if (!fileStat) {
			throw error(404, 'Not found');
		}
 
		const metadataPromise = metadata(srcPath);
		const readStream = createReadStream(srcPath);
		const { format, width } = await metadataPromise;
 
		if (!format || !readStream || !width) {
			throw error(404, 'Not found');
		}
 
		const { searchParams } = url;
		const outputWidthString = searchParams.get('w');
		const outputWidth = outputWidthString
			? Math.min(parseInt(outputWidthString, 10), width)
			: width;
		const outputFormat = searchParams.get('format') ?? format;
 
		setHeaders({
			'Content-Type': `image/${outputFormat}`,
			'Content-Disposition': `filename= ${filename.split('.')[0]}.${outputFormat}`,
			'Cache-Control': 'public, max-age=31536000, immutable'
		});
 
		return resize(readStream, outputWidth, outputFormat);
	} catch (err: unknown) {
		const httpError = err as { status: number; message: string };
		if (httpError.status && httpError.message) {
			throw error(httpError.status, httpError.message);
		}
		const { pathname } = url;
		const message = `Error in load function for path "${pathname}": ${err as string}`;
		console.error(message);
		throw error(500, message);
	}
};
```

In brief, we check for `resize` and `format` query strings in the request URL so we can respond with the right next-gen format and size.  The `Cache-Control` header is most important here.  This is where we request Cloudflare cache for a very long time and not to check for updated images.  Finally, Sharp does the transformation magic and we use <a aria-label="Read about Streams from M D N" href="https://developer.mozilla.org/en-US/docs/Web/API/Streams_API">streams</a> to optimise serving the images.

## 🍸 Svelte CSS Image Slider Server Code

Here we use <a aria-label="View video on Svelte Kit J S O N import" href="https://rodneylab.com/sveltekit-json-import/">Vite JSON import to pull in image meta</a> from a JSON file within the project.  We return `href`s for all the images (thumbnails and large preview) here. We include the hash mentioned earlier in these values. Finally we generate a low quality Base64 placeholder for each image.  We will set these as the background image so the browser shows them while waiting for the full resolution images to load.  This is another speed optimisation.

```typescript
import type { PageServerLoad } from './$types';
import imageMeta from '$lib/data/images.json';
import { join } from 'node:path';
import { error } from '@sveltejs/kit';
import { base64, hash } from '$lib/utilities/image.server';
 
export const load: PageServerLoad = async function load({ params }) {
	try {
		const { slug: imageSlug } = params;
 
		const imagesFiles = import.meta.glob('../../assets/*.jpg');
		const images = await Promise.all(
			Object.keys(imagesFiles).map(async (path) => {
				const src = path.split('/').at(-1);
				if (!src) throw error(404, 'Not found');
 
				const [slug] = src?.split('.') ?? '';
				const {
					alt,
					credit,
					title
				}: { alt: string; credit: { name: string; profile: string }; title: string } =
					// @ts-expect-error use slug as index on JSON object
					imageMeta[slug];
				const imageHash = hash(join('src/assets', src));
				const placeholder = await base64(join('src/assets', src), 10);
				return { alt, credit, placeholder, slug, src: `/assets/${imageHash}/${src}`, title };
			})
		);
		const image = images.find(({ slug }) => slug === imageSlug);
		if (!image) throw error(404, 'Not found');
 
		return {
			image,
			images
		};
	} catch (err: unknown) {
		const httpError = err as { status: number; message: string };
		if (httpError.message) {
			throw error(httpError.status ?? 500, httpError.message);
		}
		throw error(500, err as string);
	}
};
```

Also worth a mention is the use of <a aria-label="Read more about Vite glob imports" href="https://vitejs.dev/guide/features.html#glob-import>Vite glob imports</a> in line `11`.  This finds all `*.jpg` files in the assets folder. Using a glob import instead of naming the files makes it easier to swap out the images (we still need to update the JSON meta file though).

### Server Image Utility Functions

We will need a couple of utility functions to generate the Base64 low quality placeholders and also image hashes.  Although the MD5 hashing algorithm is no longer considered suitable for cryptographic purposes, it is fine to use it here; an adversary has nothing to gain from finding hash collisions for our images.  Instead of using the full hash, we truncate it to just <a aria-label="Read more about cache busting and hashes" href="https://blog.risingstack.com/automatic-cache-busting-for-your-css/">10 characters, which should be sufficient for use in identifying changes</a> in the images.

We use a Remix convention here in naming the server image utilities. While with Remix the `.server` portion means the code can only run on the server, as far as know, SvelteKit has no such guarantee and the name is just to help us import the right files.

```typescript
import crypto from 'node:crypto';
import { readFileSync } from 'node:fs';
import sharp from 'sharp';
 
export async function base64(imageSource: string, width = 10): Promise<string> {
	const image = sharp(imageSource);
	const bufferPromise = image
		.resize(width, null)
		.jpeg({
			quality: 50,
			progressive: true,
			optimiseScans: true,
			chromaSubsampling: '4:2:0',
			trellisQuantisation: true,
			quantisationTable: 2
		})
		.toBuffer({ resolveWithObject: false });
	const metadataPromise = image.metadata();
 
	const [buffer, { format }] = await Promise.all([bufferPromise, metadataPromise]);
 
	return `data:image/${format};base64,${buffer.toString('base64')}`;
}
 
export function hash(path: string): string {
	const fileBuffer = readFileSync(path);
	const hashSum = crypto.createHash('md5');
	hashSum.update(fileBuffer);
	return hashSum.digest('hex').slice(0, 10);
}
```

We use sharp here to generate the Base64 placeholders and the <a aria-label="Read more on the node Crypto module" href="https://nodejs.org/docs/latest-v16.x/api/crypto.html">node Crypto module for hashes</a>.

## 🧑🏽 Image Page Frontend Code

This is the frontend template code for our page. However the interesting image optimisations and CSS are in the two component files we look at in the next two sections.

```svelte
<script lang="ts">
	import Image from '$lib/components/Image.svelte';
	import Thumbnails from '$lib/components/Thumbnails.svelte';
	import type { PageData } from './$types';
 
	export let data: PageData;
 
	let { image, images } = data;
	$: ({ image, images } = { ...data });
 
	let currentSlug = image.slug;
</script>

<svelte:head>
	<title>{image.title} | Svelte CSS Image Slider</title>
	<meta
		name="description"
		content="Svelte CSS image slider: how to create a CSS :hover preview image gallery with mobile bouncing overscroll using future CSS and SvelteKit."
	/>
</svelte:head>
 
<main class="container">
	<div class="wrapper">
		<h1 class="heading">Svelte CSS Image Slider</h1>
		<section class="preview-container">
			<Image {image} />
		</section>
		<div class="thumbnails-wrapper">
			<Thumbnails {currentSlug} {images} />
		</div>
	</div>
</main>
 
<style lang="postcss">
	.container {
		display: grid;
		place-items: center;
		grid-template-columns: var(--max-width-full);
		min-height: 100vh;
		width: var(--max-width-full);
		padding-top: var(--spacing-12);
	}
 
	.wrapper {
		display: grid;
		grid-template-columns: var(--max-width-full);
		grid-template-areas: 'preview' 'thumbnails' 'heading';
		grid-template-rows: auto auto auto;
		width: calc(var(--max-width-full) - var(--spacing-12));
		padding: var(--spacing-6) var(--spacing-outer) var(--spacing-24);
		max-width: var(--max-width-full);
	}
  
	@media (width > 48rem) {
		.wrapper {
			padding: var(--spacing-6) auto var(--spacing-24);
			width: min(var(--max-width-full), var(--_max-width-wrapper));
		}
	}
 
	.preview-container {
		grid-area: preview;
		aspect-ratio: 16 / 10;
		border-radius: var(--spacing-px-2);
		margin-bottom: var(--spacing-6);
		max-width: 100%;
	}
 
	.thumbnails-wrapper {
		grid-area: thumbnails;
		position: relative;
		min-height: var(--spacing-28);
 
		@media (width > 80rem) {
			min-height: var(--spacing-36);
		}
	}
 
	h1 {
		grid-area: heading;
		color: var(--colour-light);
		font-size: var(--font-size-3);
		font-weight: var(--font-weight-bold);
		text-align: right;
		width: var(--max-width-full);
 
		@media (width > 42rem) {
			font-size: var(--font-size-4);
		}
 
		@media (width > 64rem) {
			font-size: var(--font-size-5);
		}
	}
</style>
```

## 🌄 Large Image Component

This is the component for the large preview image.  Possibly most important here is <a aria-label="Read more about Cumulative Layout Shift" href="https://rodneylab.com/sveltekit-image-plugin/#cumulative-layout-shift">setting image heights and widths in the HTML template code to reduce Cumulative Layout Shift</a>.  On top we set `aspect-ratio` in the CSS as well as set `height` to auto.

We mention the image source set briefly before.  Including this lets the browser choose the most adequate image for the device.  We choose image densities of `1` and `2`, needed for Retina displays. Then, we have image widths of `280px`, `334px`, `672px`, `768px`, `1024px`. These correspond to image sizes needed for a small `360px` wide screen, the iPhone XR (widest common mobile) and the desktop breakpoints.  We have utility functions to generate the sets and the final code will look something like this:

```html
<picture>
  <source
    sizes="(max-width: 768px) 100vw, (max-width: 1024px) 672px, (max-width: 1280px) 768px, 1024px"
    srcset="
      /assets/36a9348086/palenque.jpg?w=2048&amp;format=avif 2048w,
      /assets/36a9348086/palenque.jpg?w=1536&amp;format=avif 1536w,
      /assets/36a9348086/palenque.jpg?w=1344&amp;format=avif 1344w,
      /assets/36a9348086/palenque.jpg?w=1024&amp;format=avif 1024w,
      /assets/36a9348086/palenque.jpg?w=768&amp;format=avif   768w,
      /assets/36a9348086/palenque.jpg?w=748&amp;format=avif   748w,
      /assets/36a9348086/palenque.jpg?w=672&amp;format=avif   672w,
      /assets/36a9348086/palenque.jpg?w=560&amp;format=avif   560w,
      /assets/36a9348086/palenque.jpg?w=374&amp;format=avif   374w,
      /assets/36a9348086/palenque.jpg?w=280&amp;format=avif   280w
    "
    type="image/avif"
    width="1024"
    height="576" />
  <source
    sizes="(max-width: 768px) 100vw, (max-width: 1024px) 672px, (max-width: 1280px) 768px, 1024px"
    srcset="
      /assets/36a9348086/palenque.jpg?w=2048&amp;format=webp 2048w,
			...TRUNCATED...
      /assets/36a9348086/palenque.jpg?w=280&amp;format=webp   280w"
    type="image/webp"
    width="1024"
    height="576" />
  <source
    sizes="(max-width: 768px) 100vw, (max-width: 1024px) 672px, (max-width: 1280px) 768px, 1024px"
    srcset="
      /assets/36a9348086/palenque.jpg?w=2048&amp;format=jpeg 2048w,
			...TRUNCATED...
      /assets/36a9348086/palenque.jpg?w=280&amp;format=jpeg   280w"
    type="image/jpeg"
    width="1024"
    height="576"
    class="s-wHckl4XSACcy" />
  <img class="preview s-wHckl4XSACcy" alt="Palenque Mayan temple dwarfs visitors
  amid green grass strong Méxican sunlight"
  src="/assets/36a9348086/palenque.jpg?w=1024" width="1024" height="576"
  fetchpriority="high" style="background:
  url(&quot;data:image/jpeg;base64,/9j/...TRUNCATED&quot;) 0% 0% / cover;">
</picture>
```

### Image Component Code

Remember the query strings in the image URLs tell our server endpoint what size and format to serve. We opt for graceful degradation in the source sets, so start (optimistically) with AVIF, the newest and smallest next-gen format.  If the browser does not support this, them we fall back to WebP and finally JPEG.  For each set, the sizes attribute helps the browser choose which image to download while it is still working out the page layout.  Although we could possibly cut down on the number of images here, we make the point that auto generation is a massive convenience.

Anyway here is the component code (add it to `src/lib/components/Image.svelte`):

```svelte
<script lang="ts">
	import { getSrcset } from '$lib/utilities/image';
	import '@fontsource/figtree';
 
	export let image: {
		alt: string;
		credit: { name: string; profile: string };
		title: string;
		placeholder: string;
		slug: string;
		src: string;
	};
 
	let {
		alt,
		credit: { name: artist, profile },
		src
	} = image;
 
	const densities = [1.0, 2.0];
	const SIZES = [280, 374, 672, 768, 1024];
	const avifSrcset = getSrcset({
		densities,
		format: 'avif',
		sizes: SIZES,
		src
	});
	const webpSrcset = getSrcset({
		densities,
		format: 'webp',
		sizes: SIZES,
		src
	});
	const jpgSrcset = getSrcset({
		densities,
		format: 'jpeg',
		sizes: SIZES,
		src
	});
	const sizes =
		'(max-width: 768px) 100vw, (max-width: 1024px) 672px, (max-width: 1280px) 768px, 1024px';
</script>
 
<svelte:head>
	<link
		rel="preload"
		as="image"
		href={src}
		imagesrcset={avifSrcset}
		imagesizes={sizes}
		fetchpriority="high"
	/>
</svelte:head>
 
<figure>
	<picture>
		<source {sizes} srcset={avifSrcset} type="image/avif" width={1024} height={576} />
		<source {sizes} srcset={webpSrcset} type="image/webp" width={1024} height={576} />
		<source {sizes} srcset={jpgSrcset} type="image/jpeg" width={1024} height={576} />
		<img
			style:background="url('{image.placeholder}')"
			style:background-size="cover"
			class="preview"
			{alt}
			src={`${src}?w=1024`}
			width={1024}
			height={576}
			fetchpriority="high"
		/>
	</picture>
	<figcaption>CREDIT: <a href={profile}>{artist}</a></figcaption>
</figure>
 
<style lang="postcss">
	.preview {
		border-radius: var(--spacing-px-2);
		box-shadow: var(--shadow-elevation-medium);
		outline: var(--spacing-px) solid var(--colour-brand);
		max-width: 100%;
		aspect-ratio: 16 / 10;
		height: auto;
	}
 
	figure {
		margin: var(--spacing-0);
	}
 
	img {
		background-position: center center;
		background-repeat: no-repeat;
	}
 
	figcaption {
		width: var(--max-width-full);
		text-align: right;
		margin-top: var(--spacing-2);
		color: var(--colour-light);
	}
 
	figcaption a {
		color: inherit;
	}
</style>
```

As a final note here, the `rel=preload` we include in the HTML `head` is a way of <a aria-label="Read about preloading image source sets in Chromium"href="https://web.dev/preload-responsive-images/">preloading image source sets in Chromium</a>.  <a aria-label="Read about priority hints" href="https://web.dev/priority-hints/">`fetchpriority` is a newer priority hint again supported by Chromium</a>. This replaces the older `importance` attribute.  Finally, including the `rel=preload` link should instruct Cloudflare to cache the images using the Early Hints API.  As well as using Cloudflare as a CDN, you will need to enable it on your domain in the the console to get this working.

### Svelte CSS Image Slider: Client Image Utility Functions

We have a few utility functions to help generate the image sets.  Since the client code needs these, they go in `src/lib/utilities/image.ts` and not the `.server` variant we created earlier.

```typescript
export interface ImageSrcInput {
	format: string;
	src: string;
	width: number;
}
 
interface ImageWidthsInput {
	densities: number[];
	sizes: number[];
}
 
type ImageSrcsetInput = ImageWidthsInput & Omit<ImageSrcInput, 'width'>;
 
function getSortedWidths({ densities, sizes }: ImageWidthsInput): number[] {
	const widths = new Set<number>();
	sizes.forEach((size) => {
		densities.forEach((density) => widths.add(Math.round(size * density)));
	});
 
	return [...widths].sort((a, b) => b - a);
}
 
export function getSrcset({ densities, sizes, src, format }: ImageSrcsetInput): string {
	const sortedWidths = getSortedWidths({ densities, sizes });
 
	return sortedWidths
		.map((element) => `${src}?w=${element}&format=${format} ${element}w`)
		.join(',');
}
```

## 👍🏽 Thumbnails Component: Svelte Code

This is where the exciting CSS is.  We dissect it in the following sections. The markup is not too different to the main image.  You might notice we include stub bookend `<div aria-hidden="true" class="overscroller" />`. These are used in Adam Argyle&rsquo;s bouncy scroll, We&rsquo;ll come to them later!

Create `src/lib/components/Thumbnails.svelte` with the following code:

```svelte
<script lang="ts">
	import scrollCurrentIntoView from '$lib/shared/actions/scrollCurrentIntoView';
	import { getSrcset } from '$lib/utilities/image';
 
	export let images: {
		alt: string;
		title: string;
		placeholder: string;
		slug: string;
		src: string;
	}[];
	export let currentSlug: string;
 
	const densities = [1.0, 2.0];
	const THUMBNAIL_SIZES = [144, 307, 542];
	const thumbnailSizes =
		'(max-width: 768px) 40vw, (max-width: 1024px) 287px, (max-width: 1280px) 400px, 542px';
</script>
 
<nav class="thumbnails">
	<div aria-hidden="true" class="overscroller" />
	{#each images as { alt, placeholder, src, slug }}
		{@const avifThumbnailSrcset = getSrcset({
			densities,
			format: 'avif',
			sizes: THUMBNAIL_SIZES,
			src
		})}
		{@const webpThumbnailSrcset = getSrcset({
			densities,
			format: 'webp',
			sizes: THUMBNAIL_SIZES,
			src
		})}
		{@const jpgThumbnailSrcset = getSrcset({
			densities,
			format: 'jpeg',
			sizes: THUMBNAIL_SIZES,
			src
		})}
		<a
			use:scrollCurrentIntoView={slug === currentSlug}
			data-sveltekit-prefetch
			data-sveltekit-reload
			aria-current={slug === currentSlug}
			href={`/${slug}`}
			class="thumbnail"
		>
			<picture>
				<source sizes={thumbnailSizes} srcset={avifThumbnailSrcset} type="image/avif" width={170} />
				<source sizes={thumbnailSizes} srcset={webpThumbnailSrcset} type="image/webp" width={170} />
				<source sizes={thumbnailSizes} srcset={jpgThumbnailSrcset} type="image/jpeg" width={170} />
				<img
					style:background="url('{placeholder}')"
					style:background-size="cover"
					{alt}
					class="thumbnail-item"
					loading="lazy"
					src={`${src}?w=542}`}
					width={542}
					height={305}
				/></picture
			>
		</a>
	{/each}
	<div aria-hidden="true" class="overscroller" />
</nav>
```

## 🎬 Scroll into View Action

This snippet uses <a aria-label="Read post on cap size using Svelte actions in C S S" href="https://rodneylab.com/svelte-capsize-styling/">Svelte actions</a> &mdash;  a more convenient *Sveltey* way of adding a scroll into view query selector on the active image.  Create a `src/lib/actions` folder. In this new directory create `scrollCurrentIntoView.ts` with this code:

```typescript
function scrollCurrentIntoView(node: HTMLAnchorElement, current: boolean) {
	if (current) {
		const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

		node.scrollIntoView({
			behavior: prefersReducedMotion ? 'auto' : 'smooth',
			inline: 'center',
			block: 'end'
		});
	}
}
 
export default scrollCurrentIntoView;
```

## 💅🏽 Thumbnails Component: CSS

Here&rsquo;s the CSS code in full in case you are following along.  We will just pick out the most interesting bits to talk about in the rest of this section. You can paste the code at the end of `src/lib/components/Thumbnails.svelte`

```svelte
<style lang="postcss">
	/* Based on Jhey Thompkins CodePen: https://codepen.io/jh3y/pen/mdxggmO 
	 * and Adam Argyle git repo: https://github.com/argyleink/2022-css-day_oh-snap
	 */

	.thumbnails {
		display: flex;
		overflow-x: auto;
		border-radius: var(--spacing-2);
		gap: var(--spacing-4);
		background: var(--colour-dark);
		overscroll-behavior-x: contain;
		scroll-snap-type: x mandatory;
		padding: var(--spacing-4);
		scroll-padding: var(--spacing-4);
	}

	.thumbnails::-webkit-scrollbar {
		background: transparent;
	}

	.thumbnails::-webkit-scrollbar-thumb {
		background: var(--colour-alt);
		border-radius: var(--spacing-2);
	}

	.thumbnails::-webkit-scrollbar-track {
		background: var(--colour-dark);
		border-radius: var(--spacing-2);
	}

	.thumbnail {
		@media (width > 48rem) {
			outline: none;
			height: auto;
			display: grid;
			place-items: center;
			transition: flex 0.2s;
			flex: calc(0.2 + (var(--lerp, 0) * 0.8));
			position: relative;
		}
	}

	.thumbnails > :not(.overscroller) {
		scroll-snap-align: center;
		scroll-snap-stop: always;
	}

	.thumbnail:nth-child(2) {
		scroll-snap-align: start;
	}

	.thumbnail:nth-last-child(2) {
		scroll-snap-align: end;
	}

	.overscroller {
		display: block;
		inline-size: 15vw;
		flex: 0 0 auto;
	}

	.thumbnail-item {
		width: 40vw;
		height: auto;
		aspect-ratio: 16 / 10;
		border-radius: var(--spacing-1);

		@media (width > 48rem) {
			display: inline-block;
			border-style: none;
			width: 125px;

			transition: transform 0.2s;
			transform-origin: 50% 100%;
			position: relative;
			transform: translateY(calc(var(--lerp) * -50%));
		}

		@media (width > 64rem) {
			width: 144px;
		}

		@media (width > 80rem) {
			width: 196px;
		}
	}

	.thumbnail-item:hover {
		border: var(--spacing-px-2) solid var(--colour-light);
	}

	@media (width > 48rem) {
		.overscroller {
			display: none;
		}

		.thumbnails {
			position: absolute;
			top: 0;
			left: 0;
			overflow-x: unset;
			display: flex;
			align-items: center;
			justify-content: center;
			gap: var(--spacing-2);
			padding: var(--spacing-2);
		}
	}

	.thumbnails:hover,
	.thumbnails:focus-within {
		@media (width > 48rem) {
			--show: 1;
			height: var(--spacing-24);
		}
	}

	[aria-current='true'] .thumbnail-item {
		outline: var(--spacing-px-2) solid var(--colour-brand);
	}

	@media (width > 48rem) {
		.thumbnail .thumbnail-item {
			transition: outline 0.2s;
			outline: var(--colour-brand) var(--spacing-px) solid;
		}

		:is(.thumbnails:hover, .thumbnails:focus-within) .thumbnail-item {
			width: var(--max-width-full);
		}

		:is(.thumbnail:hover, .thumbnail:focus-visible) {
			--lerp: var(--lerp-0);
			z-index: 5;
		}
		.thumbnail:has(+ :is(.thumbnail:hover, .thumbnail:focus-visible)),
		:is(.thumbnail:hover, .thumbnail:focus-visible) + .thumbnail {
			--lerp: var(--lerp-1);
			z-index: 4;
		}
		.thumbnail:has(+ .thumbnail + :is(.thumbnail:hover, .thumbnail:focus-visible)),
		:is(.thumbnail:hover, .thumbnail:focus-visible) + .thumbnail + .thumbnail {
			--lerp: var(--lerp-2);
			z-index: 3;
		}
		.thumbnail:has(+ .thumbnail + .thumbnail + :is(.thumbnail:hover, .thumbnail:focus-visible)),
		:is(.thumbnail:hover, .thumbnail:focus-visible) + .thumbnail + .thumbnail + .thumbnail {
			--lerp: var(--lerp-3);
			z-index: 2;
		}
		.thumbnail:has(+ .thumbnail
				+ .thumbnail
				+ .thumbnail
				+ :is(.thumbnail:hover, .thumbnail:focus-visible)),
		:is(.thumbnail:hover, .thumbnail:focus-visible)
			+ .thumbnail
			+ .thumbnail
			+ .thumbnail
			+ .thumbnail {
			--lerp: var(--lerp-4);
			z-index: 1;
		}
	}

	a {
		color: var(--colour-alt);
	}

	picture {
		display: flex;
	}

	img {
		background-position: center center;
		background-repeat: no-repeat;
	}
</style>
```

We won&rsquo;t go into detail on the `:hover` steps effect here.  Basically it is set using `lerp` CSS custom properties.  These are defined in the CSS in `src/routes/[slug]/+layout`. Lerp stands for linear interpolation and is a function used helps smooth motion in video games. Here <a aria-label="Open Jhey's Code Pen" href="https://codepen.io/jh3y/pen/mdxggmO">Jhey uses it to for a natural</a>, physical scaling on the focussed thumbnail and adjacent ones.

The main impact is on the <a aria-label="Read about flex grow in the flexbox guide" href="https://css-tricks.com/snippets/css/a-guide-to-flexbox/#aa-flex-grow">flex-grow property</a>, which fixes the relative sizes of the `.thumbails` elements.  Tweak the <code>flex</code> property (the <code>flex</code> property is a recommended shorthand for <code>flex-grow</code>) in line <code>107</code> as well as the <code>--lerp</code> custom properties in <code>src/routes/[slug]/+layout.css</code> to get a feel for how it impacts the elements.

### Bouncy slider overscroll

Adam Argyle gave a <a aria-label="Open the C S S Day talk" href="https://youtu.be/34zcWFLCDIc">marvellous talk at CSS day on scroll sliders</a>.  I hadn&rsquo;t appreciated just how customisable they are.  This is the most relevant CSS code for the effect:

```css
	.thumbnails {
		overflow-x: auto;
		overscroll-behavior-x: contain;
		scroll-snap-type: x mandatory;
		scroll-padding: var(--spacing-4);
	}
 
	.thumbnails > :not(.overscroller) {
		scroll-snap-align: center;
		scroll-snap-stop: always;
	}
 
	.thumbnail:nth-child(2) {
		scroll-snap-align: start;
	}
 
	.thumbnail:nth-last-child(2) {
		scroll-snap-align: end;
	}
 
	.overscroller {
		display: block;
		inline-size: 15vw;
		flex: 0 0 auto;
	}
```

- `overscroll-behavior-x: contain` &mdash; this stops the whole window moving when you hit the end of the scroller,
- `scroll-snap-type: x mandatory` &mdash; use this to snap scrolling at certain points (another nice alternative is `proximity` which is less strict about things),
- `scroll-padding` &mdash; adds padding to the scroll element,
- `scroll-snap-align` &mdash; set on a thumbnail, controls whether the thumbnail is centre aligned or edge aligned when `scroll-snap-type` on scroller is `mandatory` or `proximity`.  We use centre here, but set it to start and end for first and last elements,
- `scroll-snap-stop` &mdash; nice for an image slider, stops you scrolling through dozens of thumbnails at once &mdash; make the viewer savour those thumbnails 😅.

The `.overscroller` class is an Adam Argyle trick.  Remember we added bookend stub elements to the slider? Those extend the slider slightly and the user exposes them when they overscroll. However we do not set `scroll-snap-align` on them, so when they do get exposed, the browser automatically snaps to the next *snappable* element, which is the second or second-last element (or the first or last real thumbnail).  The spring is provided natively by the browser. Is that not neat?  To allow for the stub elements we set `scroll-snap-align` to `start` and `end` for the second and penultimate element rather that first and last ones.

## 🚧 Redirection

A final detail, we redirect from the &lsquo;`/`&rsquo; route to the first image using a server layout endpoint in `src/routes/+layout.server.ts`:

```typescript
import { error, redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
 
export const load: LayoutServerLoad = async function load({ url }) {
	try {
		const { pathname } = url;
		const imagesFiles = import.meta.glob('../assets/*.jpg');
		const path = Object.keys(imagesFiles)[0];
		const src = path.split('/').at(-1);
		if (!src) throw error(404, 'Not found');
 
		const [slug] = src?.split('.') ?? '';
		if (pathname === '/') throw redirect(301, slug);
		return {};
	} catch (err: unknown) {
		// rethrow a redirect error
		const redirectError = err as { status: number; location: string };
		if (redirectError.status === 301) {
			throw redirect(301, redirectError.location);
		}
  
		// process any other type of error
		console.error(err as string);
		throw error(500, err as string);
	}
};
```

## 🙌🏽 Svelte CSS Image Slider: Wrapping Up

In this post, we saw how you can use sharp and and future CSS to create a performant and feature-packed Svelte CSS image slider.  In particular, we saw:

- some **CSS code for :hover thumbnail effects**,
- how to add **bouncy overscroll sliders**,
- quite a few **optimisations for serving images**.

Please <a aria-label="Open the Rodney Lab Git Hub repo" href="https://github.com/rodneylab/sveltekit-css-hover-image-slider">clone the site from the Rodney Lab GitHub repo</a> or <a aria-label="Open on Stack Blitz" href="https://stackblitz.com/github/rodneylab/sveltekit-css-hover-image-slider">open up the site on StackBlitz</a> to try it out if you haven&rsquo;t coded along. Hope you have found this post on creating a Svelte CSS image Slider useful! In a followup post we will see how to deploy our node SvelteKit app to cloud hosting in a Linux box.  We shall maintain the focus on performance as well as consider security there. I hope you will join me and am also keen to hear what else you are doing with Svelte and ideas for future projects.  Also let me know about any possible improvements to the content above.

## 🙏🏽 Svelte CSS Image Slider: Feedback

If you have found this post useful, see links below for further related content on this site. I do hope you learned one new thing from the video. Let me know if there are any ways I can improve on it. I hope you will use the code or starter in your own projects. Be sure to share your work on Twitter, giving me a mention so I can see what you did. Finally be sure to let me know ideas for other short videos you would like to see. Read on to find ways to get in touch, further below. If you have found this post useful, even though you can only afford even a tiny contribution, please <a aria-label="Support Rodney Lab via Buy me a Coffee" href="https://rodneylab.com/giving/">consider supporting me through Buy me a Coffee</a>.

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 <a aria-label="Get in touch on Twitter" href="https://twitter.com/messages/compose?recipient_id=1323579817258831875">@askRodney</a> on Twitter and also <a aria-label="Contact Rodney Lab via Telegram" href="https://t.me/askRodney">askRodney on Telegram</a>. Also, see <a aria-label="Get in touch with Rodney Lab" href="https://rodneylab.com/contact/">further ways to get in touch with Rodney Lab</a>. I post regularly on <a aria-label="See posts on svelte kit" href="https://rodneylab.com/tags/sveltekit/">SvelteKit</a> as well as <a href="https://rodneylab.com/tags/seo/" aria-label="See posts on search engine optimisation">Search Engine Optimisation</a> among other topics. Also <a aria-label="Subscribe to the Rodney Lab newsletter" href="https://rodneylab.com/about/#newsletter">subscribe to the newsletter to keep up-to-date</a> with our latest projects.