Build an Image Carousel with Svelte

bmw2621

Ben Winchester

Posted on October 15, 2020

Build an Image Carousel with Svelte

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
  • 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
// src/App.svelte

...

<Carousel
  images={images}
  imageWidth={250}
  imageSpacing={'30px'}
 />

...
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
...
Enter fullscreen mode Exit fullscreen mode

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>
...
Enter fullscreen mode Exit fullscreen mode

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]]
  }
  ...
Enter fullscreen mode Exit fullscreen mode

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;
}
  ...
Enter fullscreen mode Exit fullscreen mode

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}}
  />
  ...
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
...
Enter fullscreen mode Exit fullscreen mode

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 install svelte-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>

Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode
šŸ’– šŸ’Ŗ šŸ™… šŸš©
bmw2621
Ben Winchester

Posted on October 15, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related