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;
  }
}

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"
Terminal
npm install embla-carousel
Terminal
yarn add embla-carousel

Examples

A simple carousel with navigation buttons and dots.

Slide 1
Slide 2
Slide 3
Slide 4
<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>

A carousel with infinite looping enabled for seamless navigation.

Slide 1
Slide 2
Slide 3
Slide 4
<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>

A carousel with free dragging enabled - no snap points, smooth scrolling anywhere.

Slide 1
Slide 2
Slide 3
Slide 4
<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.

Slide 1
Slide 2
Slide 3
Slide 4
<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.

Small
128px wide
Medium
192px wide
Large
256px wide
Custom
160px wide
Extra
224px wide
Compact
144px wide
<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>

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:

Arrow Left
Navigate to the previous slide
Arrow Right
Navigate to the next slide
Home
Jump to the first slide
End
Jump to the last slide

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

Table of contents