Building a Seamless Image Carousel in Vue

Concept and Infinite Loop Mechanics

The carousel must satisfy the following:

  • Arrows slide images left/right smoothly.
  • Indicators jump to any slide with matching highlight.
  • Hover pauses auto-play; mouse-leave resumes it.
  • Slides wrap infinitely without visible jumps.

To achieve the wrap-around affect we clone the last slide in front and the first slide in back, yielding a seven-slide strip for five originals. When the strip reaches either cloned edge we instantly reset its position to the real edge, producing the illusion of an endless loop.

Markup Skeleton

<template>
  <div class="carousel">
    <div class="viewport">
      <ul class="track" :style="trackStyle">
        <li><img :src="slides[slides.length-1].src"></li>
        <li v-for="(s,i) in slides" :key="i"><img :src="s.src"></li>
        <li><img :src="slides[0].src"></li>
      </ul>

      <button class="prev" @click="shift(600, 1)">‹</button>
      <button class="next" @click="shift(600, -1)">›</button>

      <ol class="dots">
        <li v-for="(_,i) in slides" :key="i"
            :class="{active:i===visibleIndex}"
            @click="go(i+1)"></li>
      </ol>
    </div>
  </div>
</template>

State and Helpers

data() {
  return {
    slides: [
      { src: '/img/1.jpg' },
      { src: '/img/2.jpg' },
      { src: '/img/3.jpg' },
      { src: '/img/4.jpg' },
      { src: '/img/5.jpg' }
    ],
    visibleIndex: 1,   // 1..5
    offset: -600,      // CSS translateX
    busy: false,       // transition lock
    speed: 30,         // px per frame
    auto: null
  }
},
computed: {
  trackStyle() {
    return { transform: `translate3d(${this.offset}px,0,0)` };
  }
}

Manual Navigation

shift(width, dir) {
  if (!this.busy) {
    this.busy = true;
    dir === -1 ? this.visibleIndex++ : this.visibleIndex--;
    this.wrapIndex();
    const target = this.offset + width * dir;
    this.slide(target, dir, this.speed);
  }
},
wrapIndex() {
  if (this.visibleIndex > 5) this.visibleIndex = 1;
  if (this.visibleIndex < 1) this.visibleIndex = 5;
}

Smooth Ainmation with requestAnimationFrame

slide(dest, dir, step) {
  const tick = () => {
    if ((dir === -1 && dest < this.offset) || (dir === 1 && dest > this.offset)) {
      this.offset += step * dir;
      requestAnimationFrame(tick);
    } else {
      this.offset = dest;
      this.handleEdge();
      this.busy = false;
    }
  };
  tick();
},
handleEdge() {
  if (this.offset <= -3600) this.offset = -600;
  if (this.offset >= 0)     this.offset = -3000;
}

Dot Navigation

go(targetIndex) {
  const gap = Math.abs(targetIndex - this.visibleIndex);
  if (!gap) return;
  const dir = targetIndex - this.visibleIndex > 0 ? -1 : 1;
  const distance = gap * 600;
  const fastStep = gap * this.speed;
  this.move(distance, dir, fastStep);
  this.visibleIndex = targetIndex;
  this.wrapIndex();
}

Auto-play & Pause

mounted() {
  this.start();
  window.addEventListener('blur', this.stop);
  window.addEventListener('focus', this.start);
},
beforeDestroy() {
  this.stop();
  window.removeEventListener('blur', this.stop);
  window.removeEventListener('focus', this.start);
},
methods: {
  start() {
    this.stop();
    this.auto = setInterval(() => this.shift(600, -1), 4000);
  },
  stop() {
    clearInterval(this.auto);
    this.auto = null;
  }
}

Configurable Props

props: {
  stepPx:   { type: Number, default: 30 },
  interval: { type: Number, default: 4000 }
}

Replace hard-coded values with this.stepPx and this.interval to allow parent components to tune speed and timing.

Full Component

<template>
  <div class="carousel" @mouseenter="stop" @mouseleave="start">
    <div class="viewport">
      <ul class="track" :style="trackStyle">
        <li><img :src="slides[slides.length-1].src"></li>
        <li v-for="(s,i) in slides" :key="i"><img :src="s.src"></li>
        <li><img :src="slides[0].src"></li>
      </ul>
      <button class="prev" @click="shift(600, 1)">‹</button>
      <button class="next" @click="shift(600, -1)">›</button>
      <ol class="dots">
        <li v-for="(_,i) in slides" :key="i"
            :class="{active:i===visibleIndex-1}"
            @click="go(i+1)"></li>
      </ol>
    </div>
  </div>
</template>

<script>
export default {
  name: 'ImageCarousel',
  props: {
    stepPx:   { type: Number, default: 30 },
    interval: { type: Number, default: 4000 }
  },
  data() {
    return {
      slides: [
        { src: '/img/1.jpg' },
        { src: '/img/2.jpg' },
        { src: '/img/3.jpg' },
        { src: '/img/4.jpg' },
        { src: '/img/5.jpg' }
      ],
      visibleIndex: 1,
      offset: -600,
      busy: false,
      auto: null
    };
  },
  computed: {
    trackStyle() {
      return { transform: `translate3d(${this.offset}px,0,0)` };
    }
  },
  mounted() {
    this.start();
    window.addEventListener('blur', this.stop);
    window.addEventListener('focus', this.start);
  },
  beforeDestroy() {
    this.stop();
    window.removeEventListener('blur', this.stop);
    window.removeEventListener('focus', this.start);
  },
  methods: {
    shift(width, dir) {
      if (this.busy) return;
      this.busy = true;
      dir === -1 ? this.visibleIndex++ : this.visibleIndex--;
      this.wrapIndex();
      const dest = this.offset + width * dir;
      this.slide(dest, dir, this.stepPx);
    },
    slide(dest, dir, step) {
      const tick = () => {
        if ((dir === -1 && dest < this.offset) || (dir === 1 && dest > this.offset)) {
          this.offset += step * dir;
          requestAnimationFrame(tick);
        } else {
          this.offset = dest;
          this.handleEdge();
          this.busy = false;
        }
      };
      tick();
    },
    handleEdge() {
      if (this.offset <= -3600) this.offset = -600;
      if (this.offset >= 0) this.offset = -3000;
    },
    wrapIndex() {
      if (this.visibleIndex > 5) this.visibleIndex = 1;
      if (this.visibleIndex < 1) this.visibleIndex = 5;
    },
    go(targetIndex) {
      const gap = Math.abs(targetIndex - this.visibleIndex);
      if (!gap) return;
      const dir = targetIndex - this.visibleIndex > 0 ? -1 : 1;
      const distance = gap * 600;
      const fastStep = gap * this.stepPx;
      this.slide(this.offset + distance * dir, dir, fastStep);
      this.visibleIndex = targetIndex;
      this.wrapIndex();
    },
    start() {
      this.stop();
      this.auto = setInterval(() => this.shift(600, -1), this.interval);
    },
    stop() {
      clearInterval(this.auto);
      this.auto = null;
    }
  }
};
</script>

Tags: Vue.js Carousel Component requestAnimationFrame Infinite-Scroll

Posted on Sun, 06 Sep 2026 16:01:28 +0000 by ibolui