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>