DEV Community

Cover image for Building a carousel from scratch using Vue.js
Luis Velásquez
Luis Velásquez

Posted on • Updated on

Building a carousel from scratch using Vue.js

Instead of going through a complex third-party library docs, I tried to figure out how to build a "multi-card" carousel from scratch.

Final result screenshot

For the final code, check my GitHub repo.

If you want to see a real-world example, I used the logic of this approach (inspired by a Thin Tran's tutorial) in one of my recent projects: sprout-tan.vercel.app.

1. Understanding the structure

This is the underling structure of the demo above:

structure

But let's see how it actually works:

flow

Though in this .gif every step has an animated transition, this is just to make it easier to visualize all 4 steps:

  1. Translate the .inner wrapper.
  2. Extract the first item.
  3. Paste it to the tail.
  4. Move .inner back to its original position.

In the actual implementation, only step #1 will be animated. The others will happen instantly. This is what give us the impression of an infinite/continuous navigation loop. Can't you see it? Stick with me 😉


2. Building the carousel structure

Let's start with this basic component:

<template>
  <div class="carousel">
    <div class="inner">
      <div class="card" v-for="card in cards" :key="card">
        {{ card }}
      </div>
    </div>
  </div>
  <button>prev</button>
  <button>next</button>
</template>

<script>
export default {
  data () {
    return {
      cards: [1, 2, 3, 4, 5, 6, 7, 8]
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

This is exactly the structure from section 1. The .carousel container is the frame within which the cards will move.


3. Adding styles

...

<style>
.carousel {
  width: 170px; /* ❶ */
  overflow: hidden; /* ❷ */
}

.inner {
  white-space: nowrap; /* ❸ */
}

.card {
  width: 40px;
  margin-right: 10px;
  display: inline-flex;

  /* optional */
  height: 40px;
  background-color: #39b1bd;
  color: white;
  border-radius: 4px;
  align-items: center;
  justify-content: center;
}

/* optional */
button {
  margin-right: 5px;
  margin-top: 10px;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Explanation:

: With a fixed width we make sure new items will be appended outside of the carousel's visible area. But if you have enough cards, you can make it as wide as you want.
: Using the property overflow: hidden; will allows us to crop those elements that go outside of .carousel.
: Prevents inline-block elements (or inline-flex, in our case) from wrapping once the parent space has been filled. See white-space.

Expected result:

Result 1 screenshot


4. Translating the .inner wrapper (step 1)

<template>
  ...
  <button @click="next">next</button>
</template>

<script>
export default {
  data () {
    return {
      // ...
      innerStyles: {},
      step: ''
    }
  },

  mounted () {
    this.setStep()
  },

  methods: {
    setStep () {
      const innerWidth = this.$refs.inner.scrollWidth // ❶
      const totalCards = this.cards.length
      this.step = `${innerWidth / totalCards}px` // ❷
    },

    next () {
      this.moveLeft() // ❸
    },

    moveLeft () {
      this.innerStyles = {
        transform: `translateX(-${this.step})`
      }
    }
  }
}
</script>

<style>
/* ... */

.inner {
  transition: transform 0.2s; /* ❹ */
  /* ... */
}

/* ... */
</style>
Enter fullscreen mode Exit fullscreen mode

Explanation:

: The $refs property lets you access your template refs. scrollWith gives us the width of an element, even if it's partially hidden due to overflow.
: This will dynamically set our carousel "step", which is the distance we need to translate our .inner element every time the "next" or "prev" buttons are pressed. Having this, you don't even need to specify the width of your .card elements (as long as they're all the same size).
: To move the cards we'll be translating the whole .inner wrapper, manipulating its transform property.
: transform is the property we want to animate.

Expected result:

Result 2 screenshot


5. Shifting the cards[] array (steps 2 and 3)

<script>
// ...

  next () {
    // ...
    this.afterTransition(() => { // ❶
      const card = this.cards.shift() // ❷
      this.cards.push(card) // ❸
    })
  },

  afterTransition (callback) {
    const listener = () => { // ❹
      callback()
      this.$refs.inner.removeEventListener('transitionend', listener)
    }
    this.$refs.inner.addEventListener('transitionend', listener) // ❺
  }

// ...
</script>
Enter fullscreen mode Exit fullscreen mode

Explanation:

: afterTransition() takes a callback as an argument that's going to be executed after a transition in .inner occurs.
: The Array.prototype.shift() method takes the first element out of the array and returns it.
: The Array.prototype.push() method adds an element at the end of the array.
: We define the event listener callback: listener(). It will call our actual callback and then remove itself when executed.
: We add the event listener.

I encourage you to implement the prev() method. Hint: check this MDN entry on Array operations.


6. Moving .inner back to its original position (step 4)

<script>
// ...

  next () {
    // ...

    this.afterTransition(() => {
      // ...
      this.resetTranslate() // ❶
    })
  },

  // ...

  resetTranslate () {
    this.innerStyles = {
      transition: 'none', // ❷
      transform: 'translateX(0)'
    }
  }

// ...
</script>
Enter fullscreen mode Exit fullscreen mode

Explanation:

: It resets .inner's position after shifting the cards[] array, counteracting the additional translation caused by the latter.
: We set transition to none so the reset happens instantly.

Expected result:

Result 3 screenshot


7. Final tunings

At this point, our carousel just works. But there are a few bugs:

  • Bug 1: Calling next() too often results in non-transitioned navigation. Same for prev().

We need to find a way to disable those methods during the CSS transitions. We'll be using a data property transitioning to track this state.

data () {
  return {
    // ...
    transitioning: false
  }
},

// ...

next () {
  if (this.transitioning) return

  this.transitioning = true
  // ...

  this.afterTransition(() => {
    // ...
    this.transitioning = false
  })
},
Enter fullscreen mode Exit fullscreen mode
  • Bug 2: Unlike what happens with next(), when we call prev(), the previous card doesn't slide-in. It just appears instantly.

If you watched carefully, our current implementation still differs from the structure proposed at the beginning of this tutorial. In the former the .inner's left side and the .carousel's left side aligns. In the latter the .inner's left side starts outside the .carousel's boundaries: the difference is the space that occupies a single card.

So let's keep our .inner always translated one step to the left.

// ...
mounted () {
  // ...
  this.resetTranslate()
},

// ...

moveLeft () {
  this.innerStyles = {
    transform: `translateX(-${this.step})
                translateX(-${this.step})` // ❶
  }
},

moveRight () {
  this.innerStyles = {
    transform: `translateX(${this.step})
                translateX(-${this.step})` // ❷
  }
},

// ...

resetTranslate () {
  this.innerStyles = {
    transition: 'none',
    transform: `translateX(-${this.step})`
  }
}

// ...
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • ❶ and ❷: Every time we execute moveRight() or moveLeft() we are reseting all the transform values for .inner. Therefore it becomes necessary to add that additional translateX(-${this.step}), which is the position we want all other transformations occur from.

8. Conclusion

That's it. What a trip, huh? 😅 No wonder why this is a common question in technical interviews. But now you know how to ―or another way to― build your own "multi-card" carousel.

Again, here is the full code (stars ⭐ mean a lot to me 🫶). I hope you find it useful, and please feel free to share your thoughts/improvements in the comments.

Thanks for reading!

Bonus ✨: Thanks to Matt Jenkins, you can now check an updated version that uses the Composition API with the Setup Syntax.

Top comments (11)

Collapse
 
efraimla profile image
Efraim • Edited

Hellouu i'm trying to center the cards modifying the step value but when i'm scrolling it gets lagged, idk why

Image description

Here is the example
imgur.com/a/qbrGe7V

Collapse
 
luvejo profile image
Luis Velásquez • Edited

It could be useful for you to re-enable the reset transition by commenting this line:

function resetTranslate() {
  innerStyles.value = {
    // transition: 'none',
    transform: `translateX(-${step.value})`
  }
}
Enter fullscreen mode Exit fullscreen mode

Also, increase the transition duration:

.inner {
  transition: transform 2s;
  /* ... */
}
Enter fullscreen mode Exit fullscreen mode

That way you can see what happens after you tweak translation values.

Collapse
 
azilyahia profile image
Yahia

Thank you so much!

Collapse
 
jpaez profile image
Jorge Páez Minaya

Many thanks for the tutorial Luis, it was really useful. Just wondering what would be your aproach when you have cards with different widths.

I'm basically trying to do a chip group with Vue 3:

vuetifyjs.com/en/components/chip-g...

While testing your approach everything works fine but the two bugs that I find is that the first card is allways cut-off from the container, and the transitions are kinda weird.

Will really appreciate your feedback, and thanks in advance !

Collapse
 
luvejo profile image
Luis Velásquez

Hey, Jorge 👋 I'm pleased to read it was useful to you.

If the order of the cards is important to your project, I think you can initially shift the array so the first element becomes the second one.

Regarding the animations, did you tried using different CSS transition properties? In production I would probably try something slower. Or what do you mean by "the transitions are kinda weird"?

Finally, you would probably need both the width of the hidden card and the with of the one next to it to calculate the .inner translation. Not sure. Did you solved it yet?

Collapse
 
jpaez profile image
Jorge Páez Minaya • Edited

Thanks a lot for your reply. So I downloaded your code and tried it and the main problem is with the static width of the cards. If you don't have a static with on every card you will see that the transitions when you go "prev" or "next" looks strange. The carousel will make the animation and right after that it will add the next element to the inner container visible pushing the others. I cam to that conclusion because I just change your cards to a variable width and have exactly the same problem with the carousel I'm trying to code.

Also, I noticed when testing your code that as soon as you load the component the first card that you see in the carousel is the number "2" card not the number "1" is this correct ?

Really hope you can give me a hand here since I find your explanation super useful but just want it to make it work with that variable width approach.

Can you develop a little bit more about "you would probably need both the width of the hidden card and the with of the one next to it to calculate the .inner translation".

Really appreciated Luis !

Collapse
 
it_will_pass profile image
wide awake

Hi, thank you so much for elaborating step by step! However, when the quantity of the carousel items is less than the number of items that are showing -- e.g. mine is showing 3 per view, but has 4 in quantity) -- whenever the carousel is looping, the last item is shown like appearing instantly (not sliding), is this expected?

And when the quantity is 3 (and should showing 3 per view), it always take the last item shown and put it at the start so there's a 1 item gap. So it's only showing 2 (when it's should show 3 but it shows 2 with 1 gap at the end), like the entire carousel is translatedX(-${this.step})

do you have any suggesting how to tackle this? I feel like it's given because of the small quantity of total items compares to quantity of items shown. cmiiw

Collapse
 
jamols09 profile image
jamols09

This is the most descriptive carousel tutorial I've ever read!

Collapse
 
lisabee224 profile image
Lisa Buch

This was so helpful! However, I am getting some flickering (on images only) right after resetTranslate fires -- any ideas on how to fix this?

Collapse
 
rafalbochniewic profile image
Rafał Bochniewicz • Edited

I had the same flickering problem in Nuxt3 with . So I replaced nuxt-img with backgroundImage in styles and it's fine. But I haven't really checked what's causing it.
You can also try to add css to image:
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
-moz-backface-visibility: hidden;
-ms-backface-visibility: hidden;

Collapse
 
luvejo profile image
Luis Velásquez

That's strange. Did you see the flickering happening in the demo?