Build an Image Carousel with Svelte
Background This week I was working on a Svelte project and wanted to create a carousel for...
Background
This week I was working on a Svelte project and wanted to create a carousel for images to cycle for the user. I found a great package by boyank, svelte-carousel. The package is a Svelte component implementation of Siema. The tool is great, but after playing with it I wanted to try to create a carousel with just Svelte. You can view the recorded stream here:
This article is for those not wanted to watch a 1.5 hour stream, and goes through setting up the Svelte template and creating a Carousel component.
Lets Build
Setting up a new Svelte Project
To setup a new Svelte project run: npx degit sveltejs/template <ProjectName>
. Degit clones just the published git workspace and not the git repo (history). Then install dependencies: yarn
or npm install
. If you take a look at the package.json
you'll notice all but one dependency is a developer dependency, which highlights Svelte's greatest attribute...
What's different about Svelte
Svelte is a compiler and syntax. The entire Svelte project compiles to a single Javascript file. Svelte is not an external library that is included in the bundle like React. This allows Svelte projects to be very small and fast.
Lets prep the template so we can make the Carousel
For the sake of brevity, and because this is mostly cosmetic for the purpose of development Ill simply list what I did in the video:
- Remove props from main.js
- Update
public/global.css
- html, body: add
margin: 0
,padding: 0
,height: 100%
,width: 100%
- body: add
display: flex
,align-items: center
,justify-content: center
,background: black
- html, body: add
- Add pictures to
public/images
The global style changes will not impact the Carousel component, the default target element for injection in
rollup.config.js
is the body tag, so the changes will center and reset the body and html elements. The Carousel component itself will ultimately fill the width of whatever element it is called into, so these changes are simply cosmetic
In Svelte, the public
directory is where static assets go, so I added six jpg files in public/images
Carousel Component Setup
Ok, lets create our component at src/components/Carousel.svelte
and import it into our App.svelte
// src/App.svelte
<script>
import Carousel from './components/Carousel.svelte'
</script>
<Carousel />
<style>
</style>
And we can start building our Carousel components. We are going to create a wrapper element which will expand to the full width of its containing element. Inside of this we will create an element to hold all of our images.
// src/components/Carousel.svelte
<script>
</script>
<div id="carousel-container">
<div id="carousel-images">
</div>
</div>
<style>
</style>
Props in svelte
Now we are going to pass our images into the Carousel component. This is done by declaring an export variable in the components script tag. Then the Component tag can receive them as an attribute in the parent element.
// src/App.svelte
<script>
import Carousel from './components/Carousel.svelte'
const images = [
{path: 'images/image1.jpg', id: 'image1'},
{path: 'images/image2.jpg', id: 'image2'},
{path: 'images/image3.jpg', id: 'image3'},
{path: 'images/image4.jpg', id: 'image4'},
{path: 'images/image5.jpg', id: 'image5'},
{path: 'images/image6.jpg', id: 'image6'},
]
</script>
<Carousel images={images} />
<style>
</style>
In Svelte, if the prop and the variable being passed are the same, you can short hand the prop like so
<Carousel {images} />
.
In there Carousel element we will loop over the images prop and create an image element for each element in the array, using the path attribute as the src for the image tag, and the id tag as the alt and id for each image tag:
// src/components/Carousel.svelte
<script>
export let images;
</script>
<div id="carousel-container">
<div id="carousel-images">
{#each images as image}
<img src={image.path} alt={image.id} id={image.id} />
{/each}
</div>
</div>
<style>
</style>
Now we will see the six images appear in our component... but the are full size. Lets use props to give the user the ability to set the width and spacing for the images. Because variables cannot be accessed in the components style tags, we will have to use inline styles. When a prop declaration has an assignment, it will be the default value, and be overwritten by the passed prop if one is provided.
// src/components/Carousel.svelte
<script>
export let images;
export let imageWidth = 300;
export let imageSpacing = '25px';
</script>
<div id="carousel-container">
<div id="carousel-images">
{#each images as image}
<img
src={image.path}
alt={image.id}
id={image.id}
style={`width: ${imageWidth}px; margin: 0 {imageSpacing}`}
/>
{/each}
</div>
</div>
<style>
</style>
// src/App.svelte
...
<Carousel
images={images}
imageWidth={250}
imageSpacing={'30px'}
/>
...
Now we have some manageable image sizes, lefts style the two containers in the component to the images appear in an horizontal line. We want the overflow from the carousel-images
extend outside the horizontal edges of the carousel-container
element. Using flexbox allows us to create responsiveness. The great thing about Svelte styles, are that they are scoped to the component, so there is no worries of collisions.
// src/components/Carousel.svelte
...
<style>
#carousel-container {
width: 100%;
position: relative;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
#carousel-images {
display: flex;
justify-content: center;
flex-wrap: nowrap;
}
</style>
Add Control Buttons - A little about Svelte reactivity model
Now we are going to add some control buttons and add some functionality. We will add two buttons (so they are tab key accessible) inside our carousel-container
. Because the container is flex column, the buttons will appear at the bottom. We will position and style them at the end. To add an onClick event listener to an element add the on:click={functionName}
, and create the functions inside the script tags. Whe will discuss the actual functions in the next section.
// src/components/Carousel.svelte
<script>
export let images;
export let imageWidth = 300;
export let imageSpacing = '25px';
const rotateLeft = e => {
}
const rotateRight = e => {
}
</script>
<div id="carousel-container">
<div id="carousel-images">
{#each images as image}
<img
src={image.path}
alt={image.id}
id={image.id}
style={`width: ${imageWidth}px; margin: 0 {imageSpacing}`}
/>
{/each}
</div>
<button on:click={rotateLeft}>Left</button>
<button on:click={rotateRight}>Right</button>
</div>
...
Add animation
Another favored aspect of Svelte is its built in transitions and animations API. For the animation of the Carousel, we will use the flip animation. Flip is associated with an array element that has been rendered in a loop. When the sourcing array is reordered, the elements transition to the new order with a generated animation. The only things we need to change is importing flip, adding an element key for the each loop and provide the animate:flip
directive to the loop generated elements:
// src/components/Carousel.svelte
<script>
import { flip } from 'svelte/animate'
export let images;
export let imageWidth = 300;
export let imageSpacing = '25px';
const rotateLeft = e => {
}
const rotateRight = e => {
}
</script>
<div id="carousel-container">
<div id="carousel-images">
{#each images as image (image.id)}
<img
src={image.path}
alt={image.id}
id={image.id}
style={`width: ${imageWidth}px; margin: 0 {imageSpacing}`}
animate:flip
/>
{/each}
</div>
<button on:click={rotateLeft}>Left</button>
<button on:click={rotateRight}>Right</button>
</div>
...
Now to see the flip animation in action, we need to reorder the array in our control functions. This is were we need to discuss the reactivity model. If we mutate the images
array using array methods, Svelte will not detect the change, so we need to reorder the array and reassign it back to images
to trigger the animation. So we will use destructuring to move the first element of the array to the end (for rotateRight
) or to move the last element of the array to the beginning (for rotateLeft
).
// src/components/Carousel.svelte
...
const rotateLeft = e => {
images = [images[images.length -1],...images.slice(0, images.length - 1)]
}
const rotateRight = e => {
images = [...images.slice(1, images.length), images[0]]
}
...
Now our control buttons will show the images move to the correct location and all others will shift in accordance with the new order.
Cleanup carousel images div and flying images
The Carousel is starting to take form... but, our transitioning images are floating across the screen. The animate:flip
API does have parameters pertaining to delay and duration of the transition, but does not allow for adjusting styles. So we are going to have to target the elements directly with Javascript to change their opacity while they are moving. Because the transitioning Images stop and start off screen, the user will be unaware.
// src/components/Carousel.svelte
...
const rotateLeft = e => {
const transitioningImage = images[images.length - 1]
document.getElementById(transitioningImage.id).style.opacity = 0;
images = [images[images.length -1],...images.slice(0, images.length - 1)]
document.getElementById(transitioningImage.id).style.opacity = 1;
}
const rotateRight = e => {
const transitioningImage = images[0]
document.getElementById(transitioningImage.id).style.opacity = 0;
images = [...images.slice(1, images.length), images[0]]
document.getElementById(transitioningImage.id).style.opacity = 1;
}
...
You will notice this doesnt work... or does it? In fact, it does, but the change in opacity, the trigger for the animation, and the change of opacity back to visible all occur before the movement is complete. So we need to set a timeout to prevent the image from become visible until the transition is complete. We can do this with setTimeout(<Function>, <TimeInMilliseconds>)
. This still isnt quite enough, because the duration of the animation and the timeout need to be synchronized. To accomplish this, we will expose a prop, and pass the prop to the timeout functions and the flip animation properties.
// src/components/Carousel.svelte
...
export let transitionSpeed = 500;
...
const rotateLeft = e => {
const transitioningImage = images[images.length - 1]
document.getElementById(transitioningImage.id).style.opacity = 0;
images = [images[images.length -1],...images.slice(0, images.length - 1)]
setTimeout(() => {document.getElementById(transitioningImage.id).style.opacity = 1}, transitionSpeed);
}
const rotateRight = e => {
const transitioningImage = images[0]
document.getElementById(transitioningImage.id).style.opacity = 0;
images = [...images.slice(1, images.length), images[0]]
setTimeout(() => {document.getElementById(transitioningImage.id).style.opacity = 1}, transitionSpeed);
}
...
<img
src={image.path}
alt={image.id}
id={image.id}
style={`width: ${imageWidth}px; margin: 0 {imageSpacing}`}
animate:flip={{duration: transitionSpeed}}
/>
...
Cool! now we have fully functioning Carousel.
Lets add a little style
To give the appearance of images fading into and out of the carousel we will add a mask to the carousel-images
container:
// src/components/Carousel.svelte
...
<style>
#carousel-container {
width: 100%;
position: relative;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
#carousel-images {
display: flex;
justify-content: center;
flex-wrap: nowrap;
-webkit-mask: linear-gradient(to right,transparent,black 40%,black 60%,transparent);
mask: linear-gradient(to right, transparent, black 40%, black 60%, transparent);
}
</style>
As a side note, you may notice that the images are not centered. This is because there is an even number of images. To overcome this... only pass in an odd number of images.
Svelte slots and styling the controls
The pattern for allowing customizable controls I used directin from beyonk's
svelte-carousel
.
First lets style and position the button elements of the component so they are centered on the carousel. Note, this is why we gave the carousel-container
a position of 'relative' earlier in the tutorial.
// src/components/Carousel.svelte
...
button {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
}
button:focus {
outline: auto;
}
#left {
left: 10px;
}
#right {
right: 10px;
}
</style>
Slots
Svelte slots allow child elements to be passed to a component. All elements passed as children will be rendered in the <slot></slot>
tags inside the component. Anything placed inside the slot tags in the component will be a default fallback if no children are passed in to the Component. Also, we can arrange children with named slots. We can do this by giving the child element a slot attribute where we identify the name of the targeted slot, and then give the targeted slot the name attribute to identify it by.
// src/components/Carousel.svelte
...
<button on:click={rotateLeft}>
<slot name="left-control">Left</slot>
</button>
<button on:click={rotateRight}
<slot name="right-control">Right</slot>
</button>
...
Right now,, nothing will have changed, because we havent passed any children to the component in
App.svelte
. For the sake of demonstration, we can installsvelte-feather-icons
(or any other icon provider) and pass them as children. In this case we cannot give the Icon components slot props, so we will wrap them in span tags that will hold the slot attribute.
// src/App.svelte
<script>
import Carousel from './components/Carousel.svelte';
import { ChevronLeftIcon, ChevronRightIcon } from 'svelte-feather-icons';
const images = [
{path: 'images/image1.jpg', id: 'image1'},
{path: 'images/image2.jpg', id: 'image2'},
{path: 'images/image3.jpg', id: 'image3'},
{path: 'images/image4.jpg', id: 'image4'},
{path: 'images/image5.jpg', id: 'image5'},
// {path: 'images/image6.jpg', id: 'image6'},
]
</script>
<Carousel
{images}
imageWidth={250}
imageSpacing={15}
>
<span slot="left-control"><ChevronLeftIcon size="20" /></span>
<span slot="right-control"><ChevronRightIcon size="20" /></span>
</Carousel>
<style>
</style>
Conclusion
We now have a full functioning and styled carousel. I have pasted the entirety of the code below. You will notice I changed the default controls with SVGs which have some customizable styling which is exposed through component props. Check out the repo at https://github.com/bmw2621/svelte-carousel. Thanks for reading, and check back for the next article which will add autoplay to the carousel.
Edit: I have posted a continuation at Building an Image Carousel with Svelte - Part 2 (Adding Features)
// src/somponents/Carousel.svelte
<script>
import { flip } from 'svelte/animate';
export let images;
export let imageWidth = 300;
export let imageSpacing = 20;
export let speed = 500;
export let controlColor= '#444';
export let controlScale = '0.5';
const rotateLeft = e => {
const transitioningImage = images[images.length - 1]
document.getElementById(transitioningImage.id).style.opacity = 0;
images = [images[images.length -1],...images.slice(0, images.length - 1)]
setTimeout(() => (document.getElementById(transitioningImage.id).style.opacity = 1), speed);
}
const rotateRight = e => {
const transitioningImage = images[0]
document.getElementById(transitioningImage.id).style.opacity = 0;
images = [...images.slice(1, images.length), images[0]]
setTimeout(() => (document.getElementById(transitioningImage.id).style.opacity = 1), speed);
}
</script>
<div id="carousel-container">
<div id="carousel-images">
{#each images as image (image.id)}
<img
src={image.path}
alt={image.id}
id={image.id}
style={`width:${imageWidth}px; margin: 0 ${imageSpacing}px;`}
animate:flip={{duration: speed}}/>
{/each}
</div>
<button id="left" on:click={rotateLeft}>
<slot name="left-control">
<svg width="39px" height="110px" id="svg8" transform={`scale(${controlScale})`}>
<g id="layer1" transform="translate(-65.605611,-95.36949)">
<path
style={`fill:none;stroke:${controlColor};stroke-width:9.865;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1`}
d="m 99.785711,100.30199 -23.346628,37.07648 c -7.853858,12.81098 -7.88205,12.81098 0,24.78902 l 23.346628,37.94647"
id="path1412" />
</g>
</svg>
</slot>
</button>
<button id="right" on:click={rotateRight}>
<slot name="right-control">
<svg width="39px" height="110px" id="svg8" transform={`rotate(180) scale(${controlScale})`}>
<g id="layer1" transform="translate(-65.605611,-95.36949)">
<path
style={`fill:none;stroke:${controlColor};stroke-width:9.865;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1`}
d="m 99.785711,100.30199 -23.346628,37.07648 c -7.853858,12.81098 -7.88205,12.81098 0,24.78902 l 23.346628,37.94647"
id="path1412" />
</g>
</svg>
</slot>
</div>
<style>
#carousel-container {
width: 100%;
position: relative;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
#carousel-images {
display: flex;
justify-content: center;
flex-wrap: nowrap;
-webkit-mask: linear-gradient(
to right,
transparent,
black 40%,
black 60%,
transparent
);
mask: linear-gradient(
to right,
transparent,
black 40%,
black 60%,
transparent
);
}
button {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
}
button:focus {
outline: auto;
}
#left {
left: 10px;
}
#right {
right: 10px;
}
</style>