Vue componentFree

Carousel

Carousel is a flexible Vue component for showcasing content with smooth horizontal or vertical rotation. Designed for both simplicity and style, it lets you present images, cards, or custom elements in an engaging, interactive way that adapts to any layout.

v1.0.1Updated last year

Install
Full screen ↗

Code

16 files in the repo
<template>
	<div
		class="scene"
		:class="orientation"
		ref="scene"
	>
		<button
			class="nav-button left"
			@click="prev"
		>
			<svg
				width="50%"
				viewBox="0 0 15 15"
				fill="none"
				xmlns="http://www.w3.org/2000/svg"
			>
				<path
					fill-rule="evenodd"
					clip-rule="evenodd"
					d="M8.818 4.182a.45.45 0 0 1 0 .636L6.136 7.5l2.682 2.682a.45.45 0 1 1-.636.636l-3-3a.45.45 0 0 1 0-.636l3-3a.45.45 0 0 1 .636 0"
					fill="currentColor"
				/>
			</svg>
		</button>
		<div
			class="carousel"
			ref="carousel"
		>
			<slot>
				<img src="@/assets/alps.png" />
			</slot>
		</div>
		<button
			class="nav-button right"
			@click="next"
		>
			<svg
				width="50%"
				transform="matrix(-1,0,0,1,0,0)"
				viewBox="0 0 15 15"
				fill="none"
				xmlns="http://www.w3.org/2000/svg"
				version="1.1"
				xmlns:xlink="http://www.w3.org/1999/xlink"
			>
				<path
					fill-rule="evenodd"
					clip-rule="evenodd"
					d="M8.818 4.182a.45.45 0 0 1 0 .636L6.136 7.5l2.682 2.682a.45.45 0 1 1-.636.636l-3-3a.45.45 0 0 1 0-.636l3-3a.45.45 0 0 1 .636 0"
					fill="currentColor"
				></path>
			</svg>
		</button>
	</div>
</template>
<script>
	export default {
		name: 'Carousel3D',
		inject: ['theme'],
		props: {
			orientation: {
				type: String,
				default: 'horizontal',
				options: [{
					key: 'Horizontal',
					value: 'horizontal'
				}, {
					key: 'Vertical',
					value: 'vertical'
				}]
			},
			buttonBackground: {
				type: String,
				default: '',
				control: 'color'
			},
			buttonBackgroundHover: {
				type: String,
				default: '',
				control: 'color'
			},
			buttonColor: {
				type: String,
				default: '',
				control: 'color'
			},
			buttonColorHover: {
				type: String,
				default: '',
				control: 'color'
			}
		},
		data() {
			return {
				selectedIndex: 0,
				cellWidth: 0,
				cellHeight: 0,
				radius: 0,
				theta: 0,
				isDragging: false,
				startPos: 0,
				currentAngle: 0,
				dragAngle: 0
			};
		},
		computed: {
			background() {
				if (this.buttonBackground) return this.buttonBackground;
				if (this.theme === 'dark') return 'hsla(0, 0%, 0%, 0.4)';
				return 'hsla(0, 0%, 100%, 0.4)';
			},
			backgroundHover() {
				if (this.buttonBackgroundHover) return this.buttonBackgroundHover;
				if (this.theme === 'dark') return 'hsla(0, 0%, 0%, 0.8)';
				return 'hsla(0, 0%, 100%, 0.8)';
			},
			color() {
				if (this.buttonColor) return this.buttonColor;
				if (this.theme === 'dark') return 'hsla(0, 0%, 100%, 0.6)';
				return 'hsla(0, 0%, 0%, 0.6)';
			},
			colorHover() {
				if (this.buttonColorHover) return this.buttonColorHover;
				if (this.theme === 'dark') return 'hsla(0, 0%, 100%, 0.8)';
				return 'hsla(0, 0%, 0%, 0.8)';
			},
			isHorizontal() {
				return this.orientation === 'horizontal';
			},
			rotateFn() {
				return this.isHorizontal ? 'rotateY' : 'rotateX';
			},
			cells() {
				if (!this.$refs.carousel) return [];
				const children = Array.from(this.$refs.carousel.children);
				const len = children.length;
				if (len === 1) {
					const a = children[0].cloneNode(true);
					const b = children[0].cloneNode(true);
					this.$refs.carousel.appendChild(a);
					this.$refs.carousel.appendChild(b);
					return [children[0], a, b];
				}
				if (len === 2) {
					const a = children[0].cloneNode(true);
					const b = children[1].cloneNode(true);
					this.$refs.carousel.appendChild(a);
					this.$refs.carousel.appendChild(b);
					return [children[0], children[1], a, b];
				}
				return children;
			},
			cellCount() {
				return this.cells.length;
			}
		},
		mounted() {
			this.cellWidth = this.$refs.scene.offsetWidth;
			this.cellHeight = this.$refs.scene.offsetHeight;
			this.updateCarousel(true);
			this.slotObserver = new MutationObserver(() => {
				this.updateCarousel(true);
			});
			this.slotObserver.observe(this.$refs.carousel, {
				childList: true,
				// detect added/removed elements
				subtree: false // only direct children
			});
			this.$refs.scene.addEventListener('pointerdown', this.onPointerDown);
			window.addEventListener('pointerup', this.onPointerUp);
			window.addEventListener('pointermove', this.onPointerMove);
			window.addEventListener('resize', this.updateCarouselInitial);
			window.addEventListener('orientationchange', this.updateCarouselInitial);
			if (screen.orientation) screen.orientation.addEventListener('change', this.updateCarouselInitial);
		},
		beforeUnmount() {
			this.$refs.scene.removeEventListener('pointerdown', this.onPointerDown);
			window.removeEventListener('pointerup', this.onPointerUp);
			window.removeEventListener('pointermove', this.onPointerMove);
			window.removeEventListener('resize', this.updateCarouselInitial);
			window.removeEventListener('orientationchange', this.updateCarouselInitial);
			if (screen.orientation) screen.orientation.removeEventListener('change', this.updateCarouselInitial);
		},
		methods: {
			updateCarouselInitial() {
				this.updateCarousel(true);
			},
			updateCarousel(initial = false) {
				if (this.cellCount === 0) return;
				this.cellWidth = this.$refs.scene.offsetWidth;
				this.cellHeight = this.$refs.scene.offsetHeight;
				this.theta = 360 / this.cellCount;
				const cellSize = this.isHorizontal ? this.cellWidth : this.cellHeight;
				this.radius = Math.round(cellSize / 2 / Math.tan(Math.PI / this.cellCount));
				this.cells.forEach((cell, i) => {
					const cellAngle = this.theta * i;
					cell.style.width = this.cellWidth + 'px';
					cell.style.height = this.cellHeight + 'px';
					cell.style.transform = `${this.rotateFn}(${cellAngle}deg) translateZ(${this.radius}px)`;
					cell.style.opacity = 1;
					if (!initial) {
						cell.style.transition = 'transform 1s, opacity 1s';
					} else {
						cell.style.transition = 'transform 0s, opacity 0s';
					}
				});
				this.rotateCarousel(initial);
			},
			rotateCarousel(initial = false) {
				const angle = this.theta * this.selectedIndex * -1;
				this.currentAngle = angle;
				if (!initial) {
					this.$refs.carousel.style.transition = 'transform 1s';
				} else {
					this.$refs.carousel.style.transition = 'transform 0s';
				}
				this.$refs.carousel.style.transform = `translateZ(${-this.radius}px) ${this.rotateFn}(${angle}deg)`;
			},
			rotateInstant(angle) {
				this.$refs.carousel.style.transition = 'none';
				this.$refs.carousel.style.transform = `translateZ(${-this.radius}px) ${this.rotateFn}(${angle}deg)`;
			},
			prev() {
				this.selectedIndex--;
				this.rotateCarousel();
			},
			next() {
				this.selectedIndex++;
				this.rotateCarousel();
			},
			// pointer events
			onPointerDown(e) {
				this.isDragging = true;
				this.startPos = this.isHorizontal ? e.clientX : e.clientY;
				this.dragAngle = this.currentAngle;
				this.$refs.carousel.style.transition = 'none';
			},
			onPointerMove(e) {
				if (!this.isDragging) return;
				const currentPos = this.isHorizontal ? e.clientX : e.clientY;
				let delta = currentPos - this.startPos;
				if (!this.isHorizontal) delta = -delta;
				const rotationPerPixel = this.theta / (this.isHorizontal ? this.cellWidth : this.cellHeight);
				const angle = this.dragAngle + delta * rotationPerPixel;
				this.rotateInstant(angle);
			},
			onPointerUp() {
				if (!this.isDragging) return;
				this.isDragging = false;
				const match = this.$refs.carousel.style.transform.match(/rotate[XY]\((-?\d+(\.\d+)?)deg\)/);
				let angle = match ? parseFloat(match[1]) : this.currentAngle;
				const index = Math.round(angle / -this.theta);
				this.selectedIndex = index;
				this.rotateCarousel();
			}
		}
	};

</script>
<style scoped>
	.scene {
		width: 100%;
		aspect-ratio: 16 / 9;
		perspective: 1000px;
		position: relative;
		overflow: hidden;
		display: flex;
		align-items: center;
		justify-content: center;
	}

	.carousel {
		width: 100%;
		height: 100%;
		position: absolute;
		/* change from absolute */
		transform-style: preserve-3d;
	}

	.carousel>* {
		position: absolute;
		width: 100%;
		height: 100%;
		box-sizing: border-box;
		display: flex;
		align-items: center;
		justify-content: center;
	}

	.carousel img {
		width: 100%;
		height: 100%;
		object-fit: cover;
		pointer-events: none;
	}

	.nav-button {
		position: absolute;
		top: 50%;
		transform: translateY(-50%);
		background: v-bind(background);
		color: v-bind(color);
		border: none;
		font-size: 2rem;
		cursor: pointer;
		z-index: 2;
		border-radius: 50%;
		display: flex;
		align-items: 0;
		justify-content: center;
		width: 50px;
		height: 50px;
		transition: background linear .2s, color linear .2s;
	}

	.nav-button:hover {
		background: v-bind(backgroundHover);
		color: v-bind(colorHover);
	}

	.nav-button.left {
		left: 20px;
	}

	.nav-button.right {
		right: 20px;
	}

</style>

Props

PropTypeDefaultDescription
orientationString'horizontal'—
buttonBackgroundString''—
buttonBackgroundHoverString''—
buttonColorString''—
buttonColorHoverString''—
All media & effects →
Preview of TiltFree

Component · Media & effects

Tilt

Tilt is an interactive Vue component that brings depth and motion to any element. It reacts smoothly to mouse movement, tilting toward the pointer while casting dynamic shadows, creating a realistic 3D effect that makes your UI feel more alive and engaging.

by Steffan Halvorsen
Preview of Testimonial CarouselFree

Component · Testimonials

Testimonial Carousel

Accessible Vue 3 testimonial carousel with prev/next, dots and autoplay that pauses on hover or focus and honours reduced motion. Tailwind CSS.

by Vueplay
Preview of Hero SplitFree

Component · Hero sections

Hero Split

Split Vue 3 hero section with copy and two CTAs on the left and a Tailwind-drawn app window mockup on the right. Dark mode, typed props, no images.

by Vueplay1 installs
Preview of Bento GridFree

Component · Feature sections

Bento Grid

Bento-style Vue 3 feature grid with wide and tall tiles and small CSS-drawn visuals like maps, timelines and code. Tailwind CSS, dark mode, typed tiles.

by Vueplay

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings