Carousel Rails Component
A carousel component that allows you to create a carousel of images or text.
Installation
1. Stimulus Controller Setup
Start by adding the following controller to your project:
import { Controller } from "@hotwired/stimulus";
import EmblaCarousel from "embla-carousel";
export default class extends Controller {
static targets = ["viewport", "prevButton", "nextButton", "dotsContainer", "thumbnailButton"];
static values = {
loop: { type: Boolean, default: false }, // Whether to loop the carousel
dragFree: { type: Boolean, default: false }, // Whether to allow dragging
dots: { type: Boolean, default: true }, // Whether to show dots
buttons: { type: Boolean, default: true }, // Whether to show buttons
axis: { type: String, default: "x" }, // Axis of the carousel
thumbnails: { type: Boolean, default: false }, // Whether to show thumbnails
mainCarousel: { type: String, default: "" }, // ID of main carousel for thumbnail sync
};
connect() {
const options = {
loop: this.loopValue,
dragFree: this.dragFreeValue,
axis: this.axisValue,
};
this.embla = EmblaCarousel(this.viewportTarget, options);
this.boundHandleKeydown = this.handleKeydown.bind(this);
if (this.buttonsValue) {
this.setupButtons();
}
if (this.dotsValue) {
this.setupDots();
}
if (this.thumbnailsValue) {
this.setupThumbnails();
}
this.setupKeyboardNavigation(); // Always setup keyboard nav for viewport
this.embla.on("select", this.updateControls);
this.embla.on("reInit", this.updateControls);
// Safari compatibility: Ensure viewport is ready for focus
requestAnimationFrame(() => {
if (this.viewportTarget && !this.viewportTarget.getAttribute("aria-label")) {
this.viewportTarget.setAttribute("aria-label", "Image carousel, use arrow keys to navigate");
}
});
// If this is a thumbnail carousel, find and connect to main carousel
if (this.mainCarouselValue) {
this.connectToMainCarousel();
}
// If this carousel has thumbnails, register it as the main carousel
if (this.thumbnailsValue) {
this.registerAsMainCarousel();
}
// Try to establish connections after a delay to ensure all carousels are initialized
setTimeout(() => {
this.establishConnections();
}, 200);
}
disconnect() {
if (this.embla) {
this.embla.destroy();
}
this.teardownKeyboardNavigation();
// Clean up carousel connections
if (this.thumbnailCarousel) {
this.thumbnailCarousel = null;
}
if (this.mainCarousel) {
this.mainCarousel = null;
}
}
// --- Thumbnail Carousel Connection ---
connectToMainCarousel() {
// Use a small delay to ensure the main carousel is fully initialized
setTimeout(() => {
const mainElement = document.getElementById(this.mainCarouselValue);
if (mainElement) {
const mainController = this.application.getControllerForElementAndIdentifier(mainElement, "carousel");
if (mainController) {
this.mainCarousel = mainController;
mainController.thumbnailCarousel = this;
// Set up sync from thumbnail carousel to main carousel when dragging
if (this.embla) {
this.embla.on("select", this.syncWithMainCarousel.bind(this));
}
// Immediately sync thumbnail state with main carousel
if (this.hasThumbnailButtonTarget && mainController.embla) {
const selectedIndex = mainController.embla.selectedScrollSnap();
this.updateThumbnails(selectedIndex);
}
} else {
console.warn("Main carousel controller not found for ID:", this.mainCarouselValue);
}
} else {
console.warn("Main carousel element not found with ID:", this.mainCarouselValue);
}
}, 100);
}
registerAsMainCarousel() {
// This carousel will be found by thumbnail carousels
if (!this.element.id) {
console.warn("Main carousel should have an ID for thumbnail connection");
}
}
establishConnections() {
// If this is a thumbnail carousel and not yet connected
if (this.mainCarouselValue && !this.mainCarousel) {
this.connectToMainCarousel();
}
// If this is a main carousel, look for any thumbnail carousels that should connect to it
if (this.element.id && !this.mainCarouselValue) {
const thumbnailCarousels = document.querySelectorAll(`[data-carousel-main-carousel-value="${this.element.id}"]`);
thumbnailCarousels.forEach((thumbnailElement) => {
const thumbnailController = this.application.getControllerForElementAndIdentifier(thumbnailElement, "carousel");
if (thumbnailController && !thumbnailController.mainCarousel) {
thumbnailController.mainCarousel = this;
this.thumbnailCarousel = thumbnailController;
// Set up sync from thumbnail carousel to main carousel when dragging
if (thumbnailController.embla) {
thumbnailController.embla.on("select", thumbnailController.syncWithMainCarousel.bind(thumbnailController));
}
// Sync initial state
if (thumbnailController.hasThumbnailButtonTarget && this.embla) {
const selectedIndex = this.embla.selectedScrollSnap();
thumbnailController.updateThumbnails(selectedIndex);
}
}
});
}
}
// --- Thumbnail Navigation ---
setupThumbnails() {
if (this.hasThumbnailButtonTarget) {
this.thumbnailButtonTargets.forEach((button, index) => {
button.addEventListener("click", () => this.onThumbnailClick(index));
button.addEventListener("keydown", this.boundHandleKeydown);
});
// Set initial thumbnail state
this.updateThumbnails();
}
}
onThumbnailClick(index) {
if (!this.embla) return;
// If this is a thumbnail carousel, sync with main carousel
if (this.mainCarousel && this.mainCarousel.embla) {
this.mainCarousel.embla.scrollTo(index);
// Don't change focus on click, let user continue with thumbnails if they want
} else {
// This is the main carousel with thumbnails
this.embla.scrollTo(index);
// Don't change focus on click, let user continue with thumbnails if they want
}
}
syncWithMainCarousel() {
// This method is called when the thumbnail carousel's selection changes (including drag)
if (!this.embla || !this.mainCarousel || !this.mainCarousel.embla) return;
const selectedIndex = this.embla.selectedScrollSnap();
// Only sync if the main carousel is not already at this index to avoid infinite loops
if (this.mainCarousel.embla.selectedScrollSnap() !== selectedIndex) {
this.mainCarousel.embla.scrollTo(selectedIndex);
}
}
updateThumbnails(selectedIndex = null) {
if (!this.hasThumbnailButtonTarget) {
return;
}
// If no selectedIndex provided, get it from the appropriate carousel
if (selectedIndex === null) {
if (this.mainCarousel && this.mainCarousel.embla) {
// This is a thumbnail carousel, get index from main carousel
selectedIndex = this.mainCarousel.embla.selectedScrollSnap();
} else if (this.embla) {
// This is a main carousel with thumbnails
selectedIndex = this.embla.selectedScrollSnap();
} else {
selectedIndex = 0;
}
}
// Scroll the thumbnail carousel to show the active thumbnail
if (this.embla && selectedIndex >= 0 && selectedIndex < this.thumbnailButtonTargets.length) {
this.embla.scrollTo(selectedIndex);
}
this.thumbnailButtonTargets.forEach((button, index) => {
if (index === selectedIndex) {
// Active thumbnail styling
button.classList.remove("border-neutral-200", "dark:border-neutral-700");
button.classList.add("border-neutral-600", "dark:border-neutral-200");
// Update hover colors for active state
button.classList.remove("hover:border-neutral-400", "dark:hover:border-neutral-500");
button.classList.add("hover:border-neutral-700", "dark:hover:border-neutral-300");
} else {
// Inactive thumbnail styling
button.classList.remove("border-neutral-600", "hover:border-neutral-700", "dark:hover:border-neutral-300");
button.classList.add("border-neutral-200", "dark:border-neutral-700");
// Reset hover colors for inactive state
button.classList.add("hover:border-neutral-400", "dark:hover:border-neutral-500");
}
});
}
// --- Previous/Next Buttons ---
setupButtons() {
// Guard clauses moved to connect, but keep checks for target presence
if (this.hasPrevButtonTarget) {
this.prevButtonTarget.addEventListener("click", this.scrollPrev.bind(this), false);
this.prevButtonTarget.addEventListener("keydown", this.boundHandleKeydown);
} else if (this.buttonsValue) {
console.warn("Embla Carousel: 'buttonsValue' is true, but 'prevButtonTarget' is missing.");
}
if (this.hasNextButtonTarget) {
this.nextButtonTarget.addEventListener("click", this.scrollNext.bind(this), false);
this.nextButtonTarget.addEventListener("keydown", this.boundHandleKeydown);
} else if (this.buttonsValue) {
console.warn("Embla Carousel: 'buttonsValue' is true, but 'nextButtonTarget' is missing.");
}
this.updateButtonStates();
}
scrollPrev() {
if (!this.embla) return;
this.embla.scrollPrev();
// Transfer focus to viewport for immediate keyboard navigation
this.viewportTarget.focus();
}
scrollNext() {
if (!this.embla) return;
this.embla.scrollNext();
// Transfer focus to viewport for immediate keyboard navigation
this.viewportTarget.focus();
}
updateButtonStates() {
if (!this.embla || !this.buttonsValue) return; // Only run if buttons are enabled
const activeElement = document.activeElement;
let elementToFocus = null;
const canGoPrev = this.embla.canScrollPrev();
const canGoNext = this.embla.canScrollNext();
if (this.hasPrevButtonTarget) {
const willBeDisabled = !canGoPrev;
if (willBeDisabled && activeElement === this.prevButtonTarget) {
if (this.hasNextButtonTarget && canGoNext) {
elementToFocus = this.nextButtonTarget;
} else {
elementToFocus = this.viewportTarget;
}
}
this.prevButtonTarget.disabled = willBeDisabled;
}
if (this.hasNextButtonTarget) {
const willBeDisabled = !canGoNext;
if (willBeDisabled && activeElement === this.nextButtonTarget) {
if (this.hasPrevButtonTarget && canGoPrev) {
if (elementToFocus !== this.viewportTarget || activeElement !== this.prevButtonTarget) {
elementToFocus = this.prevButtonTarget;
}
} else if (!elementToFocus) {
elementToFocus = this.viewportTarget;
}
}
this.nextButtonTarget.disabled = willBeDisabled;
}
if (elementToFocus && typeof elementToFocus.focus === "function") {
elementToFocus.focus();
}
}
// --- Dot Navigation ---
setupDots() {
// Guard clause moved to connect, but keep checks for target presence
if (!this.hasDotsContainerTarget && this.dotsValue) {
console.warn("Embla Carousel: 'dotsValue' is true, but 'dotsContainerTarget' is missing.");
return;
}
if (this.hasDotsContainerTarget) {
this.generateDots();
this.updateDots();
}
}
generateDots() {
if (!this.embla || !this.hasDotsContainerTarget) return;
this.dotsContainerTarget.innerHTML = "";
this.embla.scrollSnapList().forEach((_, index) => {
const button = document.createElement("button");
button.classList.add(
"appearance-none",
"bg-transparent",
"touch-manipulation",
"inline-flex",
"no-underline",
"border-0",
"p-0",
"m-0",
"size-4",
"items-center",
"justify-center",
"rounded-full",
"outline-none",
"bg-white",
"dark:bg-neutral-800",
"focus-visible:outline-offset-1.5",
"focus-visible:outline-neutral-500",
"dark:focus-visible:outline-neutral-200",
"hover:bg-neutral-100",
"dark:hover:bg-neutral-700/75"
);
const dot = document.createElement("div");
dot.classList.add(
"w-4",
"h-4",
"rounded-full",
"shadow-[inset_0_0_0_0.15rem_#ccc]",
"dark:shadow-[inset_0_0_0_0.15rem_#fff]"
);
button.appendChild(dot);
button.type = "button";
button.addEventListener("click", () => this.onDotButtonClick(index));
button.addEventListener("keydown", this.boundHandleKeydown);
this.dotsContainerTarget.appendChild(button);
});
}
updateDots() {
if (!this.embla || !this.dotsValue || !this.hasDotsContainerTarget) return; // Only run if dots are enabled and target exists
const activeElement = document.activeElement;
const selectedIndex = this.embla.selectedScrollSnap();
let newlySelectedDotButton = null;
let aDotHadFocus = false;
if (activeElement && this.dotsContainerTarget.contains(activeElement)) {
aDotHadFocus = Array.from(this.dotsContainerTarget.children).includes(activeElement);
}
Array.from(this.dotsContainerTarget.children).forEach((dotButton, index) => {
const dot = dotButton.firstChild;
if (index === selectedIndex) {
dot.classList.remove("shadow-[inset_0_0_0_0.15rem_#ccc]", "dark:shadow-[inset_0_0_0_0.15rem_#404040]");
dot.classList.add("shadow-[inset_0_0_0_0.15rem_#333]", "dark:shadow-[inset_0_0_0_0.15rem_#fff]");
if (aDotHadFocus) {
newlySelectedDotButton = dotButton;
}
} else {
dot.classList.remove("shadow-[inset_0_0_0_0.15rem_#333]", "dark:shadow-[inset_0_0_0_0.15rem_#fff]");
dot.classList.add("shadow-[inset_0_0_0_0.15rem_#ccc]", "dark:shadow-[inset_0_0_0_0.15rem_#404040]");
}
});
if (
newlySelectedDotButton &&
newlySelectedDotButton !== activeElement &&
typeof newlySelectedDotButton.focus === "function"
) {
newlySelectedDotButton.focus();
}
}
onDotButtonClick(index) {
if (!this.embla) return;
this.embla.scrollTo(index);
// Transfer focus to viewport for immediate keyboard navigation
this.viewportTarget.focus();
}
// --- Combined Controls Update ---
updateControls = () => {
const selectedIndex = this.embla ? this.embla.selectedScrollSnap() : 0;
if (this.buttonsValue) {
this.updateButtonStates();
}
if (this.dotsValue) {
this.updateDots();
}
// Update thumbnails for this carousel (if it has them)
if (this.hasThumbnailButtonTarget) {
this.updateThumbnails(selectedIndex);
}
// Sync with connected thumbnail carousel
if (this.thumbnailCarousel && this.thumbnailCarousel.updateThumbnails) {
this.thumbnailCarousel.updateThumbnails(selectedIndex);
}
};
// --- Keyboard Navigation ---
setupKeyboardNavigation() {
// Make the viewport explicitly focusable and add visual focus styles
this.viewportTarget.setAttribute("tabindex", "0");
this.viewportTarget.setAttribute("role", "region");
this.viewportTarget.setAttribute("aria-label", "Carousel");
// Add focus styles for better Safari compatibility
this.viewportTarget.style.outline = "none";
// Set up keyboard event listeners with proper options for Safari
this.viewportTarget.addEventListener("keydown", this.boundHandleKeydown, { passive: false });
// Add click listener to ensure viewport can receive focus in Safari
this.viewportTarget.addEventListener("click", (event) => {
if (event.target === this.viewportTarget) {
this.viewportTarget.focus();
}
});
// Safari-specific: Ensure the element can receive focus
if (navigator.userAgent.includes("Safari") && !navigator.userAgent.includes("Chrome")) {
// Force focus capability in Safari
this.viewportTarget.style.webkitUserSelect = "none";
this.viewportTarget.style.userSelect = "none";
}
}
teardownKeyboardNavigation() {
this.viewportTarget.removeEventListener("keydown", this.boundHandleKeydown);
if (this.buttonsValue) {
if (this.hasPrevButtonTarget) {
this.prevButtonTarget.removeEventListener("keydown", this.boundHandleKeydown);
}
if (this.hasNextButtonTarget) {
this.nextButtonTarget.removeEventListener("keydown", this.boundHandleKeydown);
}
}
if (this.dotsValue && this.hasDotsContainerTarget) {
Array.from(this.dotsContainerTarget.children).forEach((dotButton) => {
dotButton.removeEventListener("keydown", this.boundHandleKeydown);
});
}
if (this.thumbnailsValue && this.hasThumbnailButtonTarget) {
this.thumbnailButtonTargets.forEach((button) => {
button.removeEventListener("keydown", this.boundHandleKeydown);
});
}
}
handleKeydown(event) {
if (!this.embla) return;
// Enhanced Safari compatibility: Check for both event.key and event.keyCode
const key = event.key || event.keyCode;
// Determine which carousel should handle the navigation
const targetCarousel = this.getNavigationTarget();
switch (key) {
case "ArrowLeft":
case 37: // Left arrow keyCode for older Safari versions
event.preventDefault();
event.stopPropagation();
targetCarousel.scrollPrev();
break;
case "ArrowRight":
case 39: // Right arrow keyCode for older Safari versions
event.preventDefault();
event.stopPropagation();
targetCarousel.scrollNext();
break;
case "Home":
case 36:
event.preventDefault();
event.stopPropagation();
targetCarousel.scrollTo(0);
break;
case "End":
case 35:
event.preventDefault();
event.stopPropagation();
targetCarousel.scrollTo(targetCarousel.scrollSnapList().length - 1);
break;
}
}
getNavigationTarget() {
// If this is a thumbnail carousel, navigation should control the main carousel
if (this.mainCarousel && this.mainCarousel.embla) {
return this.mainCarousel.embla;
}
// Otherwise, control this carousel
return this.embla;
}
}
2. Embla Carousel Installation
The clipboard component relies on Embla Carousel for intelligent tooltip positioning. Choose your preferred installation method:
pin "embla-carousel", to: "https://cdn.jsdelivr.net/npm/embla-carousel/embla-carousel.esm.js"
npm install embla-carousel
yarn add embla-carousel
Examples
Basic Carousel
A simple carousel with navigation buttons and dots.
<section class="overflow-hidden w-full max-w-xs sm:max-w-md md:max-w-2xl lg:max-w-4xl xl:max-w-6xl mx-auto" data-controller="carousel"
data-carousel-dots-value="true"
data-carousel-buttons-value="true">
<div class="overflow-hidden outline-none mx-4 sm:mx-6 md:mx-8 cursor-grab active:cursor-grabbing" data-carousel-target="viewport">
<div class="flex">
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 1
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 2
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 3
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 4
</div>
</div>
<div class="hidden md:flex pointer-events-none absolute inset-0 justify-between items-center px-4 sm:px-6 md:px-8">
<div class="relative h-full w-full">
<!-- Left gradient do define depending your page background color -->
<div class="w-5 bg-gradient-to-r from-white/70 dark:from-[#050505] to-transparent absolute left-0 inset-y-0"></div>
<!-- Right gradient do define depending your page background color -->
<div class="w-5 bg-gradient-to-l from-white/70 dark:from-[#050505] to-transparent absolute right-0 inset-y-0"></div>
</div>
</div>
</div>
<div class="flex justify-between items-center mt-4 px-4 sm:px-6 md:px-8">
<div class="grid grid-cols-2 gap-2 items-center">
<button class="z-10 flex items-center justify-center size-10 rounded-full bg-white p-2 text-sm font-semibold text-neutral-800 shadow-xs hover:bg-neutral-50 outline-none border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700/75 disabled:opacity-80 disabled:cursor-not-allowed" type="button" data-carousel-target="prevButton">
<svg xmlns="http://www.w3.org/2000/svg" class="text-neutral-700 dark:text-neutral-300 size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M11.5 16C11.308 16 11.116 15.9271 10.97 15.7801L4.71999 9.53005C4.42699 9.23705 4.42699 8.76202 4.71999 8.46902L10.97 2.21999C11.263 1.92699 11.738 1.92699 12.031 2.21999C12.324 2.51299 12.324 2.98803 12.031 3.28103L6.311 9.001L12.031 14.721C12.324 15.014 12.324 15.489 12.031 15.782C11.885 15.928 11.693 16.002 11.501 16.002L11.5 16Z"></path></g></svg>
</button>
<button class="flex items-center justify-center size-10 rounded-full bg-white p-2 text-sm font-semibold text-neutral-800 shadow-xs hover:bg-neutral-50 outline-none border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700/75 disabled:opacity-80 disabled:cursor-not-allowed" type="button" data-carousel-target="nextButton">
<svg xmlns="http://www.w3.org/2000/svg" class="text-neutral-700 dark:text-neutral-300 size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M13.28 8.46999L7.03 2.21999C6.737 1.92699 6.262 1.92699 5.969 2.21999C5.676 2.51299 5.676 2.98803 5.969 3.28103L11.689 9.001L5.969 14.721C5.676 15.014 5.676 15.489 5.969 15.782C6.115 15.928 6.307 16.002 6.499 16.002C6.691 16.002 6.883 15.929 7.029 15.782L13.279 9.53201C13.572 9.23901 13.572 8.76403 13.279 8.47103L13.28 8.46999Z"></path></g></svg>
</button>
</div>
<div class="z-10 flex flex-wrap justify-end items-center gap-1.5" data-carousel-target="dotsContainer"></div>
</div>
</section>
Loop Carousel
A carousel with infinite looping enabled for seamless navigation.
<section class="overflow-hidden w-full max-w-xs sm:max-w-md md:max-w-2xl lg:max-w-4xl xl:max-w-6xl mx-auto" data-controller="carousel"
data-carousel-loop-value="true"
data-carousel-dots-value="true"
data-carousel-buttons-value="true">
<div class="overflow-hidden outline-none mx-4 sm:mx-6 md:mx-8 cursor-grab active:cursor-grabbing" data-carousel-target="viewport">
<div class="flex">
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 1
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 2
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 3
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 4
</div>
</div>
<div class="hidden md:flex pointer-events-none absolute inset-0 justify-between items-center px-4 sm:px-6 md:px-8">
<div class="relative h-full w-full">
<!-- Left gradient do define depending your page background color -->
<div class="w-5 bg-gradient-to-r from-white/70 dark:from-[#050505] to-transparent absolute left-0 inset-y-0"></div>
<!-- Right gradient do define depending your page background color -->
<div class="w-5 bg-gradient-to-l from-white/70 dark:from-[#050505] to-transparent absolute right-0 inset-y-0"></div>
</div>
</div>
</div>
<div class="flex justify-between items-center mt-4 px-4 sm:px-6 md:px-8">
<div class="grid grid-cols-2 gap-2 items-center z-10">
<button class="flex items-center justify-center size-10 rounded-full bg-white p-2 text-sm font-semibold text-neutral-800 shadow-xs hover:bg-neutral-50 outline-none border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700/75 disabled:opacity-80 disabled:cursor-not-allowed" type="button" data-carousel-target="prevButton">
<svg xmlns="http://www.w3.org/2000/svg" class="text-neutral-700 dark:text-neutral-300 size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M11.5 16C11.308 16 11.116 15.9271 10.97 15.7801L4.71999 9.53005C4.42699 9.23705 4.42699 8.76202 4.71999 8.46902L10.97 2.21999C11.263 1.92699 11.738 1.92699 12.031 2.21999C12.324 2.51299 12.324 2.98803 12.031 3.28103L6.311 9.001L12.031 14.721C12.324 15.014 12.324 15.489 12.031 15.782C11.885 15.928 11.693 16.002 11.501 16.002L11.5 16Z"></path></g></svg>
</button>
<button class="flex items-center justify-center size-10 rounded-full bg-white p-2 text-sm font-semibold text-neutral-800 shadow-xs hover:bg-neutral-50 outline-none border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700/75 disabled:opacity-80 disabled:cursor-not-allowed" type="button" data-carousel-target="nextButton">
<svg xmlns="http://www.w3.org/2000/svg" class="text-neutral-700 dark:text-neutral-300 size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M13.28 8.46999L7.03 2.21999C6.737 1.92699 6.262 1.92699 5.969 2.21999C5.676 2.51299 5.676 2.98803 5.969 3.28103L11.689 9.001L5.969 14.721C5.676 15.014 5.676 15.489 5.969 15.782C6.115 15.928 6.307 16.002 6.499 16.002C6.691 16.002 6.883 15.929 7.029 15.782L13.279 9.53201C13.572 9.23901 13.572 8.76403 13.279 8.47103L13.28 8.46999Z"></path></g></svg>
</button>
</div>
<div class="flex flex-wrap justify-end items-center gap-1.5 z-10" data-carousel-target="dotsContainer"></div>
</div>
</section>
Drag Free Carousel
A carousel with free dragging enabled - no snap points, smooth scrolling anywhere.
<section class="overflow-hidden w-full max-w-xs sm:max-w-md md:max-w-2xl lg:max-w-4xl xl:max-w-6xl mx-auto" data-controller="carousel"
data-carousel-loop-value="false"
data-carousel-drag-free-value="true"
data-carousel-dots-value="true"
data-carousel-buttons-value="true">
<div class="overflow-hidden outline-none mx-4 sm:mx-6 md:mx-8 cursor-grab active:cursor-grabbing" data-carousel-target="viewport">
<div class="flex">
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 1
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 2
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 3
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 4
</div>
</div>
<div class="hidden md:flex pointer-events-none absolute inset-0 justify-between items-center px-4 sm:px-6 md:px-8">
<div class="relative h-full w-full">
<!-- Left gradient do define depending your page background color -->
<div class="w-5 bg-gradient-to-r from-white/70 dark:from-[#050505] to-transparent absolute left-0 inset-y-0"></div>
<!-- Right gradient do define depending your page background color -->
<div class="w-5 bg-gradient-to-l from-white/70 dark:from-[#050505] to-transparent absolute right-0 inset-y-0"></div>
</div>
</div>
</div>
<div class="flex justify-between items-center mt-4 px-4 sm:px-6 md:px-8">
<div class="grid grid-cols-2 gap-2 items-center z-10">
<button class="flex items-center justify-center size-10 rounded-full bg-white p-2 text-sm font-semibold text-neutral-800 shadow-xs hover:bg-neutral-50 outline-none border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700/75 disabled:opacity-80 disabled:cursor-not-allowed" type="button" data-carousel-target="prevButton">
<svg xmlns="http://www.w3.org/2000/svg" class="text-neutral-700 dark:text-neutral-300 size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M11.5 16C11.308 16 11.116 15.9271 10.97 15.7801L4.71999 9.53005C4.42699 9.23705 4.42699 8.76202 4.71999 8.46902L10.97 2.21999C11.263 1.92699 11.738 1.92699 12.031 2.21999C12.324 2.51299 12.324 2.98803 12.031 3.28103L6.311 9.001L12.031 14.721C12.324 15.014 12.324 15.489 12.031 15.782C11.885 15.928 11.693 16.002 11.501 16.002L11.5 16Z"></path></g></svg>
</button>
<button class="flex items-center justify-center size-10 rounded-full bg-white p-2 text-sm font-semibold text-neutral-800 shadow-xs hover:bg-neutral-50 outline-none border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700/75 disabled:opacity-80 disabled:cursor-not-allowed" type="button" data-carousel-target="nextButton">
<svg xmlns="http://www.w3.org/2000/svg" class="text-neutral-700 dark:text-neutral-300 size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M13.28 8.46999L7.03 2.21999C6.737 1.92699 6.262 1.92699 5.969 2.21999C5.676 2.51299 5.676 2.98803 5.969 3.28103L11.689 9.001L5.969 14.721C5.676 15.014 5.676 15.489 5.969 15.782C6.115 15.928 6.307 16.002 6.499 16.002C6.691 16.002 6.883 15.929 7.029 15.782L13.279 9.53201C13.572 9.23901 13.572 8.76403 13.279 8.47103L13.28 8.46999Z"></path></g></svg>
</button>
</div>
<div class="flex flex-wrap justify-end items-center gap-1.5 z-10" data-carousel-target="dotsContainer"></div>
</div>
</section>
Loop & Drag Free
A carousel with infinite looping enabled for seamless navigation and free dragging enabled for smooth scrolling anywhere.
<section class="overflow-hidden w-full max-w-xs sm:max-w-md md:max-w-2xl lg:max-w-4xl xl:max-w-6xl mx-auto" data-controller="carousel"
data-carousel-loop-value="true"
data-carousel-drag-free-value="true"
data-carousel-dots-value="true"
data-carousel-buttons-value="true">
<div class="overflow-hidden outline-none mx-4 sm:mx-6 md:mx-8 cursor-grab active:cursor-grabbing" data-carousel-target="viewport">
<div class="flex">
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 1
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 2
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 3
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full sm:basis-3/4 md:basis-2/3 lg:basis-1/2 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
Slide 4
</div>
</div>
<div class="hidden md:flex pointer-events-none absolute inset-0 justify-between items-center px-4 sm:px-6 md:px-8">
<div class="relative h-full w-full">
<!-- Left gradient do define depending your page background color -->
<div class="w-5 bg-gradient-to-r from-white/70 dark:from-[#050505] to-transparent absolute left-0 inset-y-0"></div>
<!-- Right gradient do define depending your page background color -->
<div class="w-5 bg-gradient-to-l from-white/70 dark:from-[#050505] to-transparent absolute right-0 inset-y-0"></div>
</div>
</div>
</div>
<div class="flex justify-between items-center mt-4 px-4 sm:px-6 md:px-8">
<div class="grid grid-cols-2 gap-2 items-center z-10">
<button class="flex items-center justify-center size-10 rounded-full bg-white p-2 text-sm font-semibold text-neutral-800 shadow-xs hover:bg-neutral-50 outline-none border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700/75 disabled:opacity-80 disabled:cursor-not-allowed" type="button" data-carousel-target="prevButton">
<svg xmlns="http://www.w3.org/2000/svg" class="text-neutral-700 dark:text-neutral-300 size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M11.5 16C11.308 16 11.116 15.9271 10.97 15.7801L4.71999 9.53005C4.42699 9.23705 4.42699 8.76202 4.71999 8.46902L10.97 2.21999C11.263 1.92699 11.738 1.92699 12.031 2.21999C12.324 2.51299 12.324 2.98803 12.031 3.28103L6.311 9.001L12.031 14.721C12.324 15.014 12.324 15.489 12.031 15.782C11.885 15.928 11.693 16.002 11.501 16.002L11.5 16Z"></path></g></svg>
</button>
<button class="flex items-center justify-center size-10 rounded-full bg-white p-2 text-sm font-semibold text-neutral-800 shadow-xs hover:bg-neutral-50 outline-none border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700/75 disabled:opacity-80 disabled:cursor-not-allowed" type="button" data-carousel-target="nextButton">
<svg xmlns="http://www.w3.org/2000/svg" class="text-neutral-700 dark:text-neutral-300 size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M13.28 8.46999L7.03 2.21999C6.737 1.92699 6.262 1.92699 5.969 2.21999C5.676 2.51299 5.676 2.98803 5.969 3.28103L11.689 9.001L5.969 14.721C5.676 15.014 5.676 15.489 5.969 15.782C6.115 15.928 6.307 16.002 6.499 16.002C6.691 16.002 6.883 15.929 7.029 15.782L13.279 9.53201C13.572 9.23901 13.572 8.76403 13.279 8.47103L13.28 8.46999Z"></path></g></svg>
</button>
</div>
<div class="flex flex-wrap justify-end items-center gap-1.5 z-10" data-carousel-target="dotsContainer"></div>
</div>
</section>
Variable Widths
A carousel with slides of different widths for flexible layouts.
<section class="overflow-hidden w-full max-w-xs sm:max-w-md md:max-w-2xl lg:max-w-4xl xl:max-w-6xl mx-auto" data-controller="carousel"
data-carousel-loop-value="false"
data-carousel-drag-free-value="false"
data-carousel-dots-value="true"
data-carousel-buttons-value="true">
<div class="overflow-hidden outline-none mx-4 sm:mx-6 md:mx-8 cursor-grab active:cursor-grabbing" data-carousel-target="viewport">
<div class="flex">
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 w-32 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
<div class="text-sm font-medium mb-2">Small</div>
<div class="text-xs text-neutral-600 dark:text-neutral-400">128px wide</div>
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 w-48 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
<div class="text-sm font-medium mb-2">Medium</div>
<div class="text-xs text-neutral-600 dark:text-neutral-400">192px wide</div>
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 w-64 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
<div class="text-sm font-medium mb-2">Large</div>
<div class="text-xs text-neutral-600 dark:text-neutral-400">256px wide</div>
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 w-40 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
<div class="text-sm font-medium mb-2">Custom</div>
<div class="text-xs text-neutral-600 dark:text-neutral-400">160px wide</div>
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 w-56 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
<div class="text-sm font-medium mb-2">Extra</div>
<div class="text-xs text-neutral-600 dark:text-neutral-400">224px wide</div>
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 w-36 min-w-0 px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
<div class="text-sm font-medium mb-2">Compact</div>
<div class="text-xs text-neutral-600 dark:text-neutral-400">144px wide</div>
</div>
</div>
<div class="hidden md:flex pointer-events-none absolute inset-0 justify-between items-center px-4 sm:px-6 md:px-8">
<div class="relative h-full w-full">
<!-- Left gradient do define depending your page background color -->
<div class="w-5 bg-gradient-to-r from-white/70 dark:from-[#050505] to-transparent absolute left-0 inset-y-0"></div>
<!-- Right gradient do define depending your page background color -->
<div class="w-5 bg-gradient-to-l from-white/70 dark:from-[#050505] to-transparent absolute right-0 inset-y-0"></div>
</div>
</div>
</div>
<div class="flex justify-between items-center mt-4 px-4 sm:px-6 md:px-8">
<div class="grid grid-cols-2 gap-2 items-center z-10">
<button class="flex items-center justify-center size-10 rounded-full bg-white p-2 text-sm font-semibold text-neutral-800 shadow-xs hover:bg-neutral-50 outline-none border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700/75 disabled:opacity-80 disabled:cursor-not-allowed" type="button" data-carousel-target="prevButton">
<svg xmlns="http://www.w3.org/2000/svg" class="text-neutral-700 dark:text-neutral-300 size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M11.5 16C11.308 16 11.116 15.9271 10.97 15.7801L4.71999 9.53005C4.42699 9.23705 4.42699 8.76202 4.71999 8.46902L10.97 2.21999C11.263 1.92699 11.738 1.92699 12.031 2.21999C12.324 2.51299 12.324 2.98803 12.031 3.28103L6.311 9.001L12.031 14.721C12.324 15.014 12.324 15.489 12.031 15.782C11.885 15.928 11.693 16.002 11.501 16.002L11.5 16Z"></path></g></svg>
</button>
<button class="flex items-center justify-center size-10 rounded-full bg-white p-2 text-sm font-semibold text-neutral-800 shadow-xs hover:bg-neutral-50 outline-none border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700/75 disabled:opacity-80 disabled:cursor-not-allowed" type="button" data-carousel-target="nextButton">
<svg xmlns="http://www.w3.org/2000/svg" class="text-neutral-700 dark:text-neutral-300 size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M13.28 8.46999L7.03 2.21999C6.737 1.92699 6.262 1.92699 5.969 2.21999C5.676 2.51299 5.676 2.98803 5.969 3.28103L11.689 9.001L5.969 14.721C5.676 15.014 5.676 15.489 5.969 15.782C6.115 15.928 6.307 16.002 6.499 16.002C6.691 16.002 6.883 15.929 7.029 15.782L13.279 9.53201C13.572 9.23901 13.572 8.76403 13.279 8.47103L13.28 8.46999Z"></path></g></svg>
</button>
</div>
<div class="flex flex-wrap justify-end items-center gap-1.5 z-10" data-carousel-target="dotsContainer"></div>
</div>
</section>
Thumbnails Carousel
A main carousel with thumbnail navigation below for image galleries.
<div class="w-full max-w-xs sm:max-w-md md:max-w-2xl lg:max-w-4xl xl:max-w-6xl mx-auto space-y-4">
<!-- Main Carousel -->
<section id="main-carousel" class="overflow-hidden w-full" data-controller="carousel"
data-carousel-loop-value="false"
data-carousel-drag-free-value="false"
data-carousel-dots-value="false"
data-carousel-buttons-value="true">
<div class="overflow-hidden outline-none mx-4 sm:mx-6 md:mx-8 cursor-grab active:cursor-grabbing" data-carousel-target="viewport">
<div class="flex">
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full min-w-0 p-8 sm:p-12 md:p-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
<div class="w-24 h-24 bg-neutral-200 dark:bg-neutral-700 rounded-full mx-auto mb-4 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 text-neutral-600 dark:text-neutral-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div class="text-2xl font-bold mb-2">Image 1</div>
<div class="text-sm opacity-90">Beautiful landscape photography</div>
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full min-w-0 p-8 sm:p-12 md:p-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
<div class="w-24 h-24 bg-neutral-200 dark:bg-neutral-700 rounded-full mx-auto mb-4 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 text-neutral-600 dark:text-neutral-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div class="text-2xl font-bold mb-2">Image 2</div>
<div class="text-sm opacity-90">Stunning nature scenes</div>
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full min-w-0 p-8 sm:p-12 md:p-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
<div class="w-24 h-24 bg-neutral-200 dark:bg-neutral-700 rounded-full mx-auto mb-4 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 text-neutral-600 dark:text-neutral-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div class="text-2xl font-bold mb-2">Image 3</div>
<div class="text-sm opacity-90">Urban architecture</div>
</div>
<div class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-full min-w-0 p-8 sm:p-12 md:p-16 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none">
<div class="w-24 h-24 bg-neutral-200 dark:bg-neutral-700 rounded-full mx-auto mb-4 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 text-neutral-600 dark:text-neutral-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div class="text-2xl font-bold mb-2">Image 4</div>
<div class="text-sm opacity-90">Abstract compositions</div>
</div>
</div>
</div>
<div class="flex justify-center items-center mt-4 px-4 sm:px-6 md:px-8">
<div class="grid grid-cols-2 gap-2 items-center">
<button class="flex items-center justify-center size-10 rounded-full bg-white p-2 text-sm font-semibold text-neutral-800 shadow-xs hover:bg-neutral-50 outline-none border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700/75 disabled:opacity-80 disabled:cursor-not-allowed" type="button" data-carousel-target="prevButton">
<svg xmlns="http://www.w3.org/2000/svg" class="text-neutral-700 dark:text-neutral-300 size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M11.5 16C11.308 16 11.116 15.9271 10.97 15.7801L4.71999 9.53005C4.42699 9.23705 4.42699 8.76202 4.71999 8.46902L10.97 2.21999C11.263 1.92699 11.738 1.92699 12.031 2.21999C12.324 2.51299 12.324 2.98803 12.031 3.28103L6.311 9.001L12.031 14.721C12.324 15.014 12.324 15.489 12.031 15.782C11.885 15.928 11.693 16.002 11.501 16.002L11.5 16Z"></path></g></svg>
</button>
<button class="flex items-center justify-center size-10 rounded-full bg-white p-2 text-sm font-semibold text-neutral-800 shadow-xs hover:bg-neutral-50 outline-none border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700/75 disabled:opacity-80 disabled:cursor-not-allowed" type="button" data-carousel-target="nextButton">
<svg xmlns="http://www.w3.org/2000/svg" class="text-neutral-700 dark:text-neutral-300 size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M13.28 8.46999L7.03 2.21999C6.737 1.92699 6.262 1.92699 5.969 2.21999C5.676 2.51299 5.676 2.98803 5.969 3.28103L11.689 9.001L5.969 14.721C5.676 15.014 5.676 15.489 5.969 15.782C6.115 15.928 6.307 16.002 6.499 16.002C6.691 16.002 6.883 15.929 7.029 15.782L13.279 9.53201C13.572 9.23901 13.572 8.76403 13.279 8.47103L13.28 8.46999Z"></path></g></svg>
</button>
</div>
</div>
</section>
<!-- Thumbnail Carousel -->
<section class="overflow-hidden w-full" data-controller="carousel"
data-carousel-loop-value="false"
data-carousel-drag-free-value="false"
data-carousel-dots-value="false"
data-carousel-buttons-value="false"
data-carousel-thumbnails-value="true"
data-carousel-main-carousel-value="main-carousel">
<div class="overflow-hidden outline-none mx-4 sm:mx-6 md:mx-8 cursor-grab active:cursor-grabbing" data-carousel-target="viewport">
<div class="flex">
<button class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-1/4 min-w-0 p-2 rounded-lg border-2 border-neutral-600 dark:border-neutral-200 bg-neutral-100 dark:bg-neutral-800 relative select-none hover:border-neutral-700 dark:hover:border-neutral-300 outline-none" data-carousel-target="thumbnailButton">
<div class="aspect-square rounded bg-neutral-200 dark:bg-neutral-700 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-neutral-600 dark:text-neutral-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
</button>
<button class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-1/4 min-w-0 p-2 rounded-lg border-2 border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 relative select-none hover:border-neutral-400 dark:hover:border-neutral-500 outline-none" data-carousel-target="thumbnailButton">
<div class="aspect-square rounded bg-neutral-200 dark:bg-neutral-700 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-neutral-600 dark:text-neutral-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
</button>
<button class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-1/4 min-w-0 p-2 rounded-lg border-2 border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 relative select-none hover:border-neutral-400 dark:hover:border-neutral-500 outline-none" data-carousel-target="thumbnailButton">
<div class="aspect-square rounded bg-neutral-200 dark:bg-neutral-700 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-neutral-600 dark:text-neutral-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
</button>
<button class="mr-2 sm:mr-3 md:mr-4 flex-shrink-0 flex-grow-0 basis-1/4 min-w-0 p-2 rounded-lg border-2 border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 relative select-none hover:border-neutral-400 dark:hover:border-neutral-500 outline-none" data-carousel-target="thumbnailButton">
<div class="aspect-square rounded bg-neutral-200 dark:bg-neutral-700 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-neutral-600 dark:text-neutral-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
</button>
</div>
</div>
</section>
</div>
Configuration
The carousel component uses Embla Carousel for smooth scrolling and provides customizable navigation controls, dots, and keyboard accessibility through a Stimulus controller.
Controller Setup
Basic carousel structure with required data attributes:
<section data-controller="carousel">
<div data-carousel-target="viewport">
<div class="flex">
<div class="slide">Slide 1</div>
<div class="slide">Slide 2</div>
<div class="slide">Slide 3</div>
</div>
</div>
<button data-carousel-target="prevButton">Previous</button>
<button data-carousel-target="nextButton">Next</button>
<div data-carousel-target="dotsContainer"></div>
</section>
Configuration Values
Prop | Description | Type | Default |
---|---|---|---|
loop
|
Enable infinite looping of slides |
Boolean
|
false
|
dragFree
|
Allow free dragging without snap points |
Boolean
|
false
|
dots
|
Show dot navigation indicators |
Boolean
|
true
|
buttons
|
Show previous/next navigation buttons |
Boolean
|
true
|
axis
|
Choose scroll axis between "x" (horizontal) and "y" (vertical) |
String
|
"x"
|
thumbnails
|
Enable thumbnail navigation mode |
Boolean
|
false
|
mainCarousel
|
ID of the main carousel to sync with (for thumbnail carousels) |
String
|
""
|
Targets
Target | Description | Required |
---|---|---|
viewport
|
The main scrollable container that holds all slides | Required |
prevButton
|
Button element for navigating to the previous slide | Optional |
nextButton
|
Button element for navigating to the next slide | Optional |
dotsContainer
|
Container where dot navigation indicators are dynamically generated | Optional |
thumbnailButton
|
Thumbnail buttons that navigate to specific slides (multiple targets allowed) | Optional |
Keyboard Navigation
The carousel supports full keyboard navigation when the viewport is focused:
Thumbnail Carousel Setup
To create a thumbnail carousel that syncs with a main carousel:
<div class="space-y-4">
<!-- Main Carousel -->
<section id="main-carousel" data-controller="carousel"
data-carousel-dots-value="false"
data-carousel-buttons-value="true">
<div data-carousel-target="viewport">
<div class="flex">
<div class="slide">Slide 1</div>
<div class="slide">Slide 2</div>
<div class="slide">Slide 3</div>
</div>
</div>
<button data-carousel-target="prevButton">Previous</button>
<button data-carousel-target="nextButton">Next</button>
</section>
<!-- Thumbnail Carousel -->
<section data-controller="carousel"
data-carousel-thumbnails-value="true"
data-carousel-main-carousel-value="main-carousel"
data-carousel-dots-value="false"
data-carousel-buttons-value="false">
<div data-carousel-target="viewport">
<div class="flex">
<button data-carousel-target="thumbnailButton">Thumb 1</button>
<button data-carousel-target="thumbnailButton">Thumb 2</button>
<button data-carousel-target="thumbnailButton">Thumb 3</button>
</div>
</div>
</section>
</div>
Key Points:
- The main carousel needs a unique
id
attribute - The thumbnail carousel uses
data-carousel-thumbnails-value="true"
- Connect them with
data-carousel-main-carousel-value="main-carousel-id"
- Each thumbnail button needs
data-carousel-target="thumbnailButton"
- Thumbnail order corresponds to slide order (first thumbnail = first slide)
Accessibility Features
- Keyboard Navigation: Full arrow key support with Home/End shortcuts
- Focus Management: Automatic focus handling when using navigation controls
- ARIA Labels: Proper labeling for screen readers with carousel region identification
- Button States: Navigation buttons are properly disabled when at carousel boundaries (unless looping)
- Touch Support: Native touch/swipe gestures on mobile devices
- Thumbnail Sync: Clicking thumbnails automatically syncs with main carousel and updates visual states