Carousel Components

Beautiful, responsive carousels built with TailwindCSS and AlpineJS. Perfect for hero sliders and image galleries.

1. Basic Carousel

Minimal AlpineJS carousel with autoplay, hover-pause, keyboard arrows, touch swipe, and pagination dots.

<!-- AlpineJS Carousel (HTML + Tailwind) -->
<div
  x-data="carousel({ interval: 5000 })"
  x-init="init()"
  @keydown.window="onKeydown"
  @mouseenter="onMouseEnter"
  @mouseleave="onMouseLeave"
  @touchstart.passive="touchstart($event)"
  @touchend.passive="touchend($event)"
  class="relative w-full overflow-hidden rounded-2xl bg-black/5 ring-1 ring-black/5"
  role="region" aria-roledescription="carousel" aria-label="Feature carousel"
>
  <div class="relative h-96">
    <template x-for="(slide, idx) in slides" :key="slide.id">
      <div x-show="current === idx" x-transition
           class="absolute inset-0" role="group" :aria-label="`Slide ${idx+1} of ${slides.length}`">
        <img :src="slide.img" :alt="slide.alt" class="h-full w-full object-cover">
        <div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-6 text-white">
          <h3 class="text-xl font-semibold" x-text="slide.title"></h3>
          <p class="mt-1 text-sm opacity-90" x-text="slide.caption"></p>
        </div>
      </div>
    </template>
  </div>

  <!-- Controls -->
  <button @click="prev" class="group absolute left-3 top-1/2 -translate-y-1/2 rounded-full bg-white/70 p-2 ring-1 ring-black/10">…</button>
  <button @click="next" class="group absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-white/70 p-2 ring-1 ring-black/10">…</button>

  <!-- Dots -->
  <div class="absolute bottom-3 inset-x-0 flex justify-center gap-2">
    <template x-for="(slide, i) in slides" :key="slide.id">
      <button @click="go(i)" class="h-2.5 w-2.5 rounded-full"
              :class="current === i ? 'bg-white' : 'bg-white/50 hover:bg-white/80'"></button>
    </template>
  </div>
</div>

<!-- Alpine helper (include once) -->
<script>
// see real implementation in the page's <script> (alpine:init)
</script>
<template>
  <div class="relative w-full overflow-hidden rounded-2xl bg-black/5 ring-1 ring-black/5" role="region" aria-roledescription="carousel">
    <div class="relative h-96">
      <div v-for="(s,i) in slides" :key="s.id" v-show="current===i" class="absolute inset-0">
        <img :src="s.img" :alt="s.alt" class="h-full w-full object-cover" />
        <div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-6 text-white">
          <h3 class="text-xl font-semibold">{{ s.title }}</h3>
          <p class="mt-1 text-sm opacity-90">{{ s.caption }}</p>
        </div>
      </div>
    </div>
    <button @click="prev" class="absolute left-3 top-1/2 -translate-y-1/2 rounded-full bg-white/70 p-2 ring-1 ring-black/10">‹</button>
    <button @click="next" class="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-white/70 p-2 ring-1 ring-black/10">›</button>
    <div class="absolute bottom-3 inset-x-0 flex justify-center gap-2">
      <button v-for="(s,i) in slides" :key="s.id" @click="go(i)" class="h-2.5 w-2.5 rounded-full" :class="current===i?'bg-white':'bg-white/50 hover:bg-white/80'"/>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const current = ref(0)
const slides = ref([
  { id:1, title:'Misty Mountains', caption:'Sunrise in the valley', alt:'', img:'https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1600&auto=format&fit=crop' },
  { id:2, title:'Ocean Breeze', caption:'Waves for days', alt:'', img:'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=1600&auto=format&fit=crop' },
  { id:3, title:'City Nights', caption:'Neon lights', alt:'', img:'https://images.unsplash.com/photo-1508057198894-247b23fe5ade?q=80&w=1600&auto=format&fit=crop' },
])
let timer; const interval = 5000
const go = (i) => current.value = (i + slides.value.length) % slides.value.length
const next = () => go(current.value + 1)
const prev = () => go(current.value - 1)
onMounted(() => { timer = setInterval(next, interval) })
onBeforeUnmount(() => clearInterval(timer))
</script>
import React, { useEffect, useRef, useState } from 'react'

export default function BasicCarousel() {
  const [current, setCurrent] = useState(0)
  const slides = [
    { id:1, title:'Misty Mountains', caption:'Sunrise in the valley', alt:'', img:'https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1600&auto=format&fit=crop' },
    { id:2, title:'Ocean Breeze', caption:'Waves for days', alt:'', img:'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=1600&auto=format&fit=crop' },
    { id:3, title:'City Nights', caption:'Neon lights', alt:'', img:'https://images.unsplash.com/photo-1508057198894-247b23fe5ade?q=80&w=1600&auto=format&fit=crop' }
  ]
  const len = slides.length
  const go = (i) => setCurrent((i + len) % len)
  const next = () => go(current + 1)
  const prev = () => go(current - 1)
  const timer = useRef(null)
  useEffect(() => {
    timer.current = setInterval(next, 5000)
    return () => clearInterval(timer.current)
  }, [current])
  return (
    <div className="relative w-full overflow-hidden rounded-2xl bg-black/5 ring-1 ring-black/5">
      <div className="relative h-96">
        {slides.map((s, i) => (
          <div key={s.id} className={`absolute inset-0 ${current===i?'opacity-100':'opacity-0'} transition-opacity duration-500`}>
            <img src={s.img} alt={s.alt} className="h-full w-full object-cover" />
            <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-6 text-white">
              <h3 className="text-xl font-semibold">{s.title}</h3>
              <p className="mt-1 text-sm opacity-90">{s.caption}</p>
            </div>
          </div>
        ))}
      </div>
      <button onClick={prev} className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full bg-white/70 p-2 ring-1 ring-black/10">‹</button>
      <button onClick={next} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-white/70 p-2 ring-1 ring-black/10">›</button>
      <div className="absolute bottom-3 inset-x-0 flex justify-center gap-2">
        {slides.map((_, i) => (
          <button key={i} onClick={() => go(i)} className={`h-2.5 w-2.5 rounded-full ${current===i?'bg-white':'bg-white/50 hover:bg-white/80'}`} />
        ))}
      </div>
    </div>
  )
}

2. Carousel with Thumbnails & Progress

Autoplay carousel with hover-pause, swipe, arrow keys, clickable thumbnails, and a progress bar.

<!-- Carousel with Thumbnails & Progress (HTML + Alpine) -->
<div x-data="thumbCarousel({ interval: 4500 })" x-init="init()" class="relative w-full rounded-2xl bg-black/5 ring-1 ring-black/5 overflow-hidden" role="region" aria-roledescription="carousel">
  <div class="relative h-96">
    <template x-for="(slide, idx) in slides" :key="slide.id">
      <div x-show="current === idx" x-transition class="absolute inset-0">
        <img :src="slide.img" :alt="slide.alt" class="h-full w-full object-cover">
        <div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-6 text-white">
          <h3 class="text-xl font-semibold" x-text="slide.title"></h3>
          <p class="mt-1 text-sm opacity-90" x-text="slide.caption"></p>
        </div>
      </div>
    </template>
    <div class="absolute left-0 right-0 bottom-0 h-1.5 bg-black/30">
      <div class="h-full bg-white/80" :style="{ width: progress + '%' }"></div>
    </div>
  </div>

  <button @click="prev" class="absolute left-3 top-1/2 -translate-y-1/2 rounded-full bg-white/70 p-2 ring-1 ring-black/10">‹</button>
  <button @click="next" class="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-white/70 p-2 ring-1 ring-black/10">›</button>

  <div class="flex items-center gap-3 overflow-x-auto p-4 bg-white/70 backdrop-blur">
    <template x-for="(slide, i) in slides" :key="slide.id">
      <button @click="go(i)" class="flex-shrink-0">
        <img :src="slide.img" :alt="slide.alt" class="h-14 w-20 object-cover rounded-lg"
             :class="current === i ? 'ring-2 ring-violet' : 'ring-1 ring-black/10 opacity-80 hover:opacity-100'">
      </button>
    </template>
  </div>
</div>
<template>
  <div class="relative w-full rounded-2xl bg-black/5 ring-1 ring-black/5 overflow-hidden" role="region" aria-roledescription="carousel">
    <div class="relative h-96">
      <div v-for="(s,i) in slides" :key="s.id" v-show="current===i" class="absolute inset-0">
        <img :src="s.img" :alt="s.alt" class="h-full w-full object-cover" />
        <div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-6 text-white">
          <h3 class="text-xl font-semibold">{{ s.title }}</h3>
          <p class="mt-1 text-sm opacity-90">{{ s.caption }}</p>
        </div>
      </div>
      <div class="absolute left-0 right-0 bottom-0 h-1.5 bg-black/30">
        <div class="h-full bg-white/80" :style="{ width: progress + '%' }"></div>
      </div>
    </div>

    <button @click="prev" class="absolute left-3 top-1/2 -translate-y-1/2 rounded-full bg-white/70 p-2 ring-1 ring-black/10">‹</button>
    <button @click="next" class="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-white/70 p-2 ring-1 ring-black/10">›</button>

    <div class="flex items-center gap-3 overflow-x-auto p-4 bg-white/70 backdrop-blur">
      <button v-for="(s,i) in slides" :key="s.id" @click="go(i)" class="flex-shrink-0">
        <img :src="s.img" :alt="s.alt" class="h-14 w-20 object-cover rounded-lg"
             :class="current===i ? 'ring-2 ring-violet' : 'ring-1 ring-black/10 opacity-80 hover:opacity-100'" />
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const current = ref(0)
const progress = ref(0)
const slides = ref([
  { id:1, title:'Misty Mountains', caption:'Sunrise in the valley', alt:'', img:'https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1600&auto=format&fit=crop' },
  { id:2, title:'Ocean Breeze', caption:'Waves for days', alt:'', img:'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=1600&auto=format&fit=crop' },
  { id:3, title:'City Nights', caption:'Neon lights', alt:'', img:'https://images.unsplash.com/photo-1508057198894-247b23fe5ade?q=80&w=1600&auto=format&fit=crop' },
])
const len = slides.value.length
const go = (i) => { current.value = (i + len) % len; resetProgress() }
const next = () => go(current.value + 1)
const prev = () => go(current.value - 1)
let slideTimer, progressTimer
const interval = 4500, step = 50
function resetProgress() { progress.value = 0 }
onMounted(() => {
  slideTimer = setInterval(next, interval)
  progressTimer = setInterval(() => {
    progress.value = Math.min(100, progress.value + (step/interval)*100)
  }, step)
})
onBeforeUnmount(() => { clearInterval(slideTimer); clearInterval(progressTimer) })
</script>
import React, { useEffect, useRef, useState } from 'react'

export default function ThumbCarousel() {
  const [current, setCurrent] = useState(0)
  const [progress, setProgress] = useState(0)
  const slides = [
    { id:1, title:'Misty Mountains', caption:'Sunrise in the valley', alt:'', img:'https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1600&auto=format&fit=crop' },
    { id:2, title:'Ocean Breeze', caption:'Waves for days', alt:'', img:'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=1600&auto=format&fit=crop' },
    { id:3, title:'City Nights', caption:'Neon lights', alt:'', img:'https://images.unsplash.com/photo-1508057198894-247b23fe5ade?q=80&w=1600&auto=format&fit=crop' }
  ]
  const len = slides.length
  const go = (i) => { setCurrent((i + len) % len); setProgress(0) }
  const next = () => go(current + 1)
  const prev = () => go(current - 1)

  const slideTimer = useRef(null)
  const progressTimer = useRef(null)
  useEffect(() => {
    slideTimer.current = setInterval(next, 4500)
    progressTimer.current = setInterval(() => {
      setProgress(p => Math.min(100, p + (50/4500)*100))
    }, 50)
    return () => { clearInterval(slideTimer.current); clearInterval(progressTimer.current) }
  }, [current])

  return (
    <div className="relative w-full rounded-2xl bg-black/5 ring-1 ring-black/5 overflow-hidden">
      <div className="relative h-96">
        {slides.map((s, i) => (
          <div key={s.id} className={`absolute inset-0 ${current===i?'opacity-100':'opacity-0'} transition-opacity duration-500`}>
            <img src={s.img} alt={s.alt} className="h-full w-full object-cover" />
            <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-6 text-white">
              <h3 className="text-xl font-semibold">{s.title}</h3>
              <p className="mt-1 text-sm opacity-90">{s.caption}</p>
            </div>
          </div>
        ))}
        <div className="absolute left-0 right-0 bottom-0 h-1.5 bg-black/30">
          {/* Escape Blade on the double-curly style prop: */}
          <div className="h-full bg-white/80" style={{ width: `\${progress}%` }} />
        </div>
      </div>

      <button onClick={prev} className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full bg-white/70 p-2 ring-1 ring-black/10">‹</button>
      <button onClick={next} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-white/70 p-2 ring-1 ring-black/10">›</button>

      <div className="flex items-center gap-3 overflow-x-auto p-4 bg-white/70 backdrop-blur">
        {slides.map((s, i) => (
          <button key={s.id} onClick={() => go(i)} className="flex-shrink-0">
            <img src={s.img} alt={s.alt} className={`h-14 w-20 object-cover rounded-lg ${current===i ? 'ring-2 ring-violet' : 'ring-1 ring-black/10 opacity-80 hover:opacity-100'}`} />
          </button>
        ))}
      </div>
    </div>
  )
}

🚀 Quick Tips

  • Replace the slides arrays with your own images/text.
  • Autoplay pauses on hover and resumes on mouse leave; arrow keys also work.
  • If Alpine throws “XYZ is not defined”, ensure this file loads after Alpine and keep the helpers inside the alpine:init listener (as done above).

We use cookies to improve your experience and analytics. You can accept all cookies or reject non-essential ones.

Learn More