Carousel Shared Partial

Follow the Installation section first!
This component requires additional setup (dependencies, gems, controllers, partials, or libraries) before using these shared partials. Please complete the Installation section on this page first.

1. Copy shared partial files

Download all 2 shared partial files as a ZIP file.

Download all files (ZIP)

After downloading, unzip and copy the carousel/ folder to your app/views/shared/components/ directory.

Copy these files to your app/views/shared/components/carousel/ directory:

2. Usage example

<!-- Basic horizontal carousel -->
<%= render "shared/components/carousel/carousel",
  slides: [
    { content: '<div class="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">Slide 1</div>' },
    { content: '<div class="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">Slide 2</div>' },
    { content: '<div class="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">Slide 3</div>' },
    { content: '<div class="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">Slide 4</div>' }
  ]
%>

<!-- Loop + drag-free carousel -->
<%= render "shared/components/carousel/carousel",
  loop: true,
  drag_free: true,
  slide_size: "third",
  slides: [
    { content: '<div class="p-6 text-center rounded-xl bg-blue-500 text-white">Card 1</div>' },
    { content: '<div class="p-6 text-center rounded-xl bg-green-500 text-white">Card 2</div>' },
    { content: '<div class="p-6 text-center rounded-xl bg-purple-500 text-white">Card 3</div>' },
    { content: '<div class="p-6 text-center rounded-xl bg-orange-500 text-white">Card 4</div>' },
    { content: '<div class="p-6 text-center rounded-xl bg-pink-500 text-white">Card 5</div>' },
    { content: '<div class="p-6 text-center rounded-xl bg-cyan-500 text-white">Card 6</div>' }
  ]
%>

<!-- Vertical carousel -->
<%= render "shared/components/carousel/carousel",
  axis: "y",
  height: "h-96",
  classes: "max-w-xs sm:max-w-md md:max-w-lg",
  slides: [
    { content: '<div class="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-2">Vertical Slide 1</div><div class="text-sm text-neutral-600 dark:text-neutral-400">Scrolls on the Y-axis</div>', classes: "basis-1/2 sm:basis-2/5 w-[88%] mx-auto p-5 sm:p-6 md:p-7 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 flex flex-col justify-center items-center" },
    { content: '<div class="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-2">Vertical Slide 2</div><div class="text-sm text-neutral-600 dark:text-neutral-400">Works for stacked content flows</div>', classes: "basis-1/2 sm:basis-2/5 w-[88%] mx-auto p-5 sm:p-6 md:p-7 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 flex flex-col justify-center items-center" },
    { content: '<div class="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-2">Vertical Slide 3</div><div class="text-sm text-neutral-600 dark:text-neutral-400">Useful for timelines and steps</div>', classes: "basis-1/2 sm:basis-2/5 w-[88%] mx-auto p-5 sm:p-6 md:p-7 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 flex flex-col justify-center items-center" },
    { content: '<div class="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-2">Vertical Slide 4</div><div class="text-sm text-neutral-600 dark:text-neutral-400">Fully keyboard and touch friendly</div>', classes: "basis-1/2 sm:basis-2/5 w-[88%] mx-auto p-5 sm:p-6 md:p-7 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 flex flex-col justify-center items-center" }
  ]
%>

<!-- Full-width media carousel -->
<%= render "shared/components/carousel/carousel",
  slide_size: "full",
  slides: [
    { content: '<img src="https://picsum.photos/1000/480?random=301" alt="Gallery image 1" class="w-full h-64 md:h-80 object-cover rounded-xl">' },
    { content: '<img src="https://picsum.photos/1000/480?random=302" alt="Gallery image 2" class="w-full h-64 md:h-80 object-cover rounded-xl">' },
    { content: '<img src="https://picsum.photos/1000/480?random=303" alt="Gallery image 3" class="w-full h-64 md:h-80 object-cover rounded-xl">' }
  ]
%>

<!-- Main carousel + thumbnails -->
<div class="space-y-4">
  <%= render "shared/components/carousel/carousel",
    id: "usage-main-carousel-erb",
    slide_size: "full",
    dots: false,
    buttons: false,
    show_gradients: false,
    slides: [
      { content: '<img src="https://picsum.photos/1000/540?random=401" alt="Main image 1" class="w-full h-64 md:h-80 object-cover rounded-xl">' },
      { content: '<img src="https://picsum.photos/1000/540?random=402" alt="Main image 2" class="w-full h-64 md:h-80 object-cover rounded-xl">' },
      { content: '<img src="https://picsum.photos/1000/540?random=403" alt="Main image 3" class="w-full h-64 md:h-80 object-cover rounded-xl">' },
      { content: '<img src="https://picsum.photos/1000/540?random=404" alt="Main image 4" class="w-full h-64 md:h-80 object-cover rounded-xl">' }
    ]
  %>

  <%= render "shared/components/carousel/carousel",
    thumbnails: true,
    main_carousel_id: "usage-main-carousel-erb",
    slide_size: "quarter",
    dots: false,
    buttons: false,
    show_gradients: false,
    slides: [
      { content: '<button data-carousel-target="thumbnailButton" class="w-full rounded-lg border-2 border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 hover:border-neutral-400 dark:hover:border-neutral-500 p-1.5 outline-hidden focus-visible:outline-offset-1.5 focus-visible:outline-neutral-500 dark:focus-visible:outline-neutral-200"><img src="https://picsum.photos/260/150?random=401" alt="Thumbnail 1" class="w-full h-16 object-cover rounded-md"></button>' },
      { content: '<button data-carousel-target="thumbnailButton" class="w-full rounded-lg border-2 border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 hover:border-neutral-400 dark:hover:border-neutral-500 p-1.5 outline-hidden focus-visible:outline-offset-1.5 focus-visible:outline-neutral-500 dark:focus-visible:outline-neutral-200"><img src="https://picsum.photos/260/150?random=402" alt="Thumbnail 2" class="w-full h-16 object-cover rounded-md"></button>' },
      { content: '<button data-carousel-target="thumbnailButton" class="w-full rounded-lg border-2 border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 hover:border-neutral-400 dark:hover:border-neutral-500 p-1.5 outline-hidden focus-visible:outline-offset-1.5 focus-visible:outline-neutral-500 dark:focus-visible:outline-neutral-200"><img src="https://picsum.photos/260/150?random=403" alt="Thumbnail 3" class="w-full h-16 object-cover rounded-md"></button>' },
      { content: '<button data-carousel-target="thumbnailButton" class="w-full rounded-lg border-2 border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 hover:border-neutral-400 dark:hover:border-neutral-500 p-1.5 outline-hidden focus-visible:outline-offset-1.5 focus-visible:outline-neutral-500 dark:focus-visible:outline-neutral-200"><img src="https://picsum.photos/260/150?random=404" alt="Thumbnail 4" class="w-full h-16 object-cover rounded-md"></button>' }
    ]
  %>
</div>

<!-- Buttons-only carousel (no dots) -->
<%= render "shared/components/carousel/carousel",
  dots: false,
  buttons: true,
  show_gradients: false,
  slides: [
    { content: '<div class="p-10 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700">Buttons-only Slide 1</div>' },
    { content: '<div class="p-10 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700">Buttons-only Slide 2</div>' },
    { content: '<div class="p-10 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700">Buttons-only Slide 3</div>' }
  ]
%>

<!-- Wheel gestures carousel -->
<%= render "shared/components/carousel/carousel",
  wheel_gestures: true,
  loop: true,
  slide_size: "quarter",
  show_gradients: false,
  slides: (1..8).map { |i| { content: "<div class='p-4 text-center rounded-lg bg-neutral-100 dark:bg-neutral-800'>Item #{i}</div>" } }
%>

Rendered usage example

Carousel ViewComponent

Follow the Installation section first!
This component requires additional setup (gems, controllers, partials, or libraries) before using this ViewComponent. Please complete the Installation section on this page first.

1. Ensure the gem is installed

gem "view_component", "~> 4.2"

2. Copy component files

Download all 4 component files as a ZIP file.

Download all files (ZIP)

After downloading, unzip and copy the carousel/ folder to your app/components/ directory.

Copy these files to your app/components/carousel/ directory:

3. Usage example

<!-- Basic horizontal carousel -->
<%= render(Carousel::Component.new) do |carousel| %>
  <% carousel.with_slide do %>
    <div class="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">
      Slide 1
    </div>
  <% end %>
  <% carousel.with_slide do %>
    <div class="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">
      Slide 2
    </div>
  <% end %>
  <% carousel.with_slide do %>
    <div class="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">
      Slide 3
    </div>
  <% end %>
  <% carousel.with_slide do %>
    <div class="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">
      Slide 4
    </div>
  <% end %>
<% end %>

<!-- Loop + drag-free carousel -->
<%= render(Carousel::Component.new(
  loop: true,
  drag_free: true,
  slide_size: :third
)) do |carousel| %>
  <% carousel.with_slide do %>
    <div class="p-6 text-center rounded-xl bg-blue-500 text-white">Card 1</div>
  <% end %>
  <% carousel.with_slide do %>
    <div class="p-6 text-center rounded-xl bg-green-500 text-white">Card 2</div>
  <% end %>
  <% carousel.with_slide do %>
    <div class="p-6 text-center rounded-xl bg-purple-500 text-white">Card 3</div>
  <% end %>
  <% carousel.with_slide do %>
    <div class="p-6 text-center rounded-xl bg-orange-500 text-white">Card 4</div>
  <% end %>
  <% carousel.with_slide do %>
    <div class="p-6 text-center rounded-xl bg-pink-500 text-white">Card 5</div>
  <% end %>
  <% carousel.with_slide do %>
    <div class="p-6 text-center rounded-xl bg-cyan-500 text-white">Card 6</div>
  <% end %>
<% end %>

<!-- Vertical carousel -->
<%= render(Carousel::Component.new(
  axis: "y",
  height: "h-96",
  classes: "max-w-xs sm:max-w-md md:max-w-lg"
)) do |carousel| %>
  <% carousel.with_slide(classes: "basis-1/2 sm:basis-2/5 w-[88%] mx-auto p-5 sm:p-6 md:p-7 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 flex flex-col justify-center items-center") do %>
    <div class="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-2">Vertical Slide 1</div>
    <div class="text-sm text-neutral-600 dark:text-neutral-400">Scrolls on the Y-axis</div>
  <% end %>
  <% carousel.with_slide(classes: "basis-1/2 sm:basis-2/5 w-[88%] mx-auto p-5 sm:p-6 md:p-7 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 flex flex-col justify-center items-center") do %>
    <div class="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-2">Vertical Slide 2</div>
    <div class="text-sm text-neutral-600 dark:text-neutral-400">Works for stacked content flows</div>
  <% end %>
  <% carousel.with_slide(classes: "basis-1/2 sm:basis-2/5 w-[88%] mx-auto p-5 sm:p-6 md:p-7 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 flex flex-col justify-center items-center") do %>
    <div class="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-2">Vertical Slide 3</div>
    <div class="text-sm text-neutral-600 dark:text-neutral-400">Useful for timelines and steps</div>
  <% end %>
  <% carousel.with_slide(classes: "basis-1/2 sm:basis-2/5 w-[88%] mx-auto p-5 sm:p-6 md:p-7 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 flex flex-col justify-center items-center") do %>
    <div class="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-2">Vertical Slide 4</div>
    <div class="text-sm text-neutral-600 dark:text-neutral-400">Fully keyboard and touch friendly</div>
  <% end %>
<% end %>

<!-- Full-width media carousel -->
<%= render(Carousel::Component.new(slide_size: :full)) do |carousel| %>
  <% carousel.with_slide do %>
    <img src="https://picsum.photos/1000/480?random=301" alt="Gallery image 1" class="w-full h-64 md:h-80 object-cover rounded-xl">
  <% end %>
  <% carousel.with_slide do %>
    <img src="https://picsum.photos/1000/480?random=302" alt="Gallery image 2" class="w-full h-64 md:h-80 object-cover rounded-xl">
  <% end %>
  <% carousel.with_slide do %>
    <img src="https://picsum.photos/1000/480?random=303" alt="Gallery image 3" class="w-full h-64 md:h-80 object-cover rounded-xl">
  <% end %>
<% end %>

<!-- Main carousel + thumbnails -->
<div class="space-y-4">
  <%= render(Carousel::Component.new(
    id: "usage-main-carousel-view-component",
    slide_size: :full,
    show_gradients: false,
    dots: false,
    buttons: false
  )) do |carousel| %>
    <% carousel.with_slide do %>
      <img src="https://picsum.photos/1000/540?random=401" alt="Main image 1" class="w-full h-64 md:h-80 object-cover rounded-xl">
    <% end %>
    <% carousel.with_slide do %>
      <img src="https://picsum.photos/1000/540?random=402" alt="Main image 2" class="w-full h-64 md:h-80 object-cover rounded-xl">
    <% end %>
    <% carousel.with_slide do %>
      <img src="https://picsum.photos/1000/540?random=403" alt="Main image 3" class="w-full h-64 md:h-80 object-cover rounded-xl">
    <% end %>
    <% carousel.with_slide do %>
      <img src="https://picsum.photos/1000/540?random=404" alt="Main image 4" class="w-full h-64 md:h-80 object-cover rounded-xl">
    <% end %>
  <% end %>

  <%= render(Carousel::Component.new(
    thumbnails: true,
    main_carousel_id: "usage-main-carousel-view-component",
    slide_size: :quarter,
    dots: false,
    buttons: false,
    show_gradients: false
  )) do |carousel| %>
    <% carousel.with_slide do %>
      <button data-carousel-target="thumbnailButton" class="w-full rounded-lg border-2 border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 hover:border-neutral-400 dark:hover:border-neutral-500 p-1.5 outline-hidden focus-visible:outline-offset-1.5 focus-visible:outline-neutral-500 dark:focus-visible:outline-neutral-200">
        <img src="https://picsum.photos/260/150?random=401" alt="Thumbnail 1" class="w-full h-16 object-cover rounded-md">
      </button>
    <% end %>
    <% carousel.with_slide do %>
      <button data-carousel-target="thumbnailButton" class="w-full rounded-lg border-2 border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 hover:border-neutral-400 dark:hover:border-neutral-500 p-1.5 outline-hidden focus-visible:outline-offset-1.5 focus-visible:outline-neutral-500 dark:focus-visible:outline-neutral-200">
        <img src="https://picsum.photos/260/150?random=402" alt="Thumbnail 2" class="w-full h-16 object-cover rounded-md">
      </button>
    <% end %>
    <% carousel.with_slide do %>
      <button data-carousel-target="thumbnailButton" class="w-full rounded-lg border-2 border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 hover:border-neutral-400 dark:hover:border-neutral-500 p-1.5 outline-hidden focus-visible:outline-offset-1.5 focus-visible:outline-neutral-500 dark:focus-visible:outline-neutral-200">
        <img src="https://picsum.photos/260/150?random=403" alt="Thumbnail 3" class="w-full h-16 object-cover rounded-md">
      </button>
    <% end %>
    <% carousel.with_slide do %>
      <button data-carousel-target="thumbnailButton" class="w-full rounded-lg border-2 border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 hover:border-neutral-400 dark:hover:border-neutral-500 p-1.5 outline-hidden focus-visible:outline-offset-1.5 focus-visible:outline-neutral-500 dark:focus-visible:outline-neutral-200">
        <img src="https://picsum.photos/260/150?random=404" alt="Thumbnail 4" class="w-full h-16 object-cover rounded-md">
      </button>
    <% end %>
  <% end %>
</div>

<!-- Buttons-only carousel (no dots) -->
<%= render(Carousel::Component.new(
  dots: false,
  buttons: true,
  show_gradients: false
)) do |carousel| %>
  <% carousel.with_slide do %>
    <div class="p-10 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700">
      Buttons-only Slide 1
    </div>
  <% end %>
  <% carousel.with_slide do %>
    <div class="p-10 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700">
      Buttons-only Slide 2
    </div>
  <% end %>
  <% carousel.with_slide do %>
    <div class="p-10 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700">
      Buttons-only Slide 3
    </div>
  <% end %>
<% end %>

<!-- Wheel gestures carousel -->
<%= render(Carousel::Component.new(
  wheel_gestures: true,
  loop: true,
  slide_size: :quarter,
  show_gradients: false
)) do |carousel| %>
  <% (1..8).each do |i| %>
    <% carousel.with_slide do %>
      <div class="p-4 text-center rounded-lg bg-neutral-100 dark:bg-neutral-800">
        Item <%= i %>
      </div>
    <% end %>
  <% end %>
<% end %>

Rendered usage example

Buttons-only Slide 1
Buttons-only Slide 2
Buttons-only Slide 3
Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
Item 8

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";
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";

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
    wheelGestures: { type: Boolean, default: false }, // Whether to enable wheel gestures
  };

  connect() {
    const options = {
      loop: this.loopValue,
      dragFree: this.dragFreeValue,
      axis: this.axisValue,
    };
    if (this.shouldCenterFirstSlide()) {
      options.align = "center";
      options.containScroll = false;
    }
    if (this.thumbnailsValue) {
      // Keep one snap per thumbnail so dot count matches thumbnail count.
      options.align = "start";
      options.containScroll = false;
    }

    const plugins = [];
    if (this.wheelGesturesValue) {
      plugins.push(WheelGesturesPlugin());
    }

    this.embla = EmblaCarousel(this.viewportTarget, options, plugins);
    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();
    }

    // Try to establish connections after a delay to ensure all carousels are initialized
    setTimeout(() => {
      this.establishConnections();
    }, 200);
  }

  shouldCenterFirstSlide() {
    return !this.loopValue && !this.thumbnailsValue;
  }

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

  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-hidden",
        "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.setAttribute("aria-label", `Go to slide ${index + 1}`);
      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]");
        dotButton.setAttribute("aria-label", `Slide ${index + 1} (current)`);
        dotButton.setAttribute("aria-current", "true");
        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]");
        dotButton.setAttribute("aria-label", `Go to slide ${index + 1}`);
        dotButton.removeAttribute("aria-current");
      }
    });

    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;
    const isVertical = this.axisValue === "y";
    const fromThumbnailButton = this.isThumbnailButtonEvent(event);

    // Determine which carousel should handle the navigation
    const targetCarousel = this.getNavigationTarget();

    switch (key) {
      case "ArrowLeft":
      case 37: // Left arrow keyCode for older Safari versions
        if (isVertical) break;
        event.preventDefault();
        event.stopPropagation();
        targetCarousel.scrollPrev();
        this.focusViewportAfterThumbnailNavigation(fromThumbnailButton);
        break;
      case "ArrowRight":
      case 39: // Right arrow keyCode for older Safari versions
        if (isVertical) break;
        event.preventDefault();
        event.stopPropagation();
        targetCarousel.scrollNext();
        this.focusViewportAfterThumbnailNavigation(fromThumbnailButton);
        break;
      case "ArrowUp":
      case 38: // Up arrow keyCode for older Safari versions
        if (!isVertical) break;
        event.preventDefault();
        event.stopPropagation();
        targetCarousel.scrollPrev();
        this.focusViewportAfterThumbnailNavigation(fromThumbnailButton);
        break;
      case "ArrowDown":
      case 40: // Down arrow keyCode for older Safari versions
        if (!isVertical) break;
        event.preventDefault();
        event.stopPropagation();
        targetCarousel.scrollNext();
        this.focusViewportAfterThumbnailNavigation(fromThumbnailButton);
        break;
      case "Home":
      case 36:
        event.preventDefault();
        event.stopPropagation();
        targetCarousel.scrollTo(0);
        this.focusViewportAfterThumbnailNavigation(fromThumbnailButton);
        break;
      case "End":
      case 35:
        event.preventDefault();
        event.stopPropagation();
        targetCarousel.scrollTo(targetCarousel.scrollSnapList().length - 1);
        this.focusViewportAfterThumbnailNavigation(fromThumbnailButton);
        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;
  }

  isThumbnailButtonEvent(event) {
    return (
      this.thumbnailsValue && this.hasThumbnailButtonTarget && this.thumbnailButtonTargets.includes(event.currentTarget)
    );
  }

  focusViewportAfterThumbnailNavigation(fromThumbnailButton) {
    if (!fromThumbnailButton) return;

    if (this.mainCarousel && this.mainCarousel.viewportTarget) {
      this.mainCarousel.viewportTarget.focus();
    } else if (this.viewportTarget) {
      this.viewportTarget.focus();
    }
  }
}

The carousel 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"
pin "embla-carousel-wheel-gestures", to: "https://cdn.jsdelivr.net/npm/embla-carousel-wheel-gestures@latest/+esm"
Terminal
npm install embla-carousel
npm install embla-carousel-wheel-gestures
Terminal
yarn add embla-carousel
yarn add embla-carousel-wheel-gestures

Examples

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-hidden mx-4 sm:mx-6 md:mx-8 cursor-grab active:cursor-grabbing md:[--carousel-fade-size:1.25rem] md:[mask-image:linear-gradient(to_right,transparent,black_var(--carousel-fade-size),black_calc(100%-var(--carousel-fade-size)),transparent)] md:[-webkit-mask-image:linear-gradient(to_right,transparent,black_var(--carousel-fade-size),black_calc(100%-var(--carousel-fade-size)),transparent)]" 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>

  <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-hidden 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" aria-label="Previous">
        <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-hidden 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" aria-label="Next">
        <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.

<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-hidden mx-4 sm:mx-6 md:mx-8 cursor-grab active:cursor-grabbing md:[--carousel-fade-size:1.25rem] md:[mask-image:linear-gradient(to_right,transparent,black_var(--carousel-fade-size),black_calc(100%-var(--carousel-fade-size)),transparent)] md:[-webkit-mask-image:linear-gradient(to_right,transparent,black_var(--carousel-fade-size),black_calc(100%-var(--carousel-fade-size)),transparent)]" 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>

  <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-hidden 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" aria-label="Previous">
        <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-hidden 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" aria-label="Next">
        <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.

<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-hidden mx-4 sm:mx-6 md:mx-8 cursor-grab active:cursor-grabbing md:[--carousel-fade-size:1.25rem] md:[mask-image:linear-gradient(to_right,transparent,black_var(--carousel-fade-size),black_calc(100%-var(--carousel-fade-size)),transparent)] md:[-webkit-mask-image:linear-gradient(to_right,transparent,black_var(--carousel-fade-size),black_calc(100%-var(--carousel-fade-size)),transparent)]" 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>

  <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-hidden 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" aria-label="Previous">
        <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-hidden 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" aria-label="Next">
        <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-hidden mx-4 sm:mx-6 md:mx-8 cursor-grab active:cursor-grabbing md:[--carousel-fade-size:1.25rem] md:[mask-image:linear-gradient(to_right,transparent,black_var(--carousel-fade-size),black_calc(100%-var(--carousel-fade-size)),transparent)] md:[-webkit-mask-image:linear-gradient(to_right,transparent,black_var(--carousel-fade-size),black_calc(100%-var(--carousel-fade-size)),transparent)]" 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>

  <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-hidden 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" aria-label="Previous">
        <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-hidden 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" aria-label="Next">
        <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-hidden mx-4 sm:mx-6 md:mx-8 cursor-grab active:cursor-grabbing md:[--carousel-fade-size:1.25rem] md:[mask-image:linear-gradient(to_right,transparent,black_var(--carousel-fade-size),black_calc(100%-var(--carousel-fade-size)),transparent)] md:[-webkit-mask-image:linear-gradient(to_right,transparent,black_var(--carousel-fade-size),black_calc(100%-var(--carousel-fade-size)),transparent)]" 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>

    <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-hidden 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" aria-label="Previous">
          <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-hidden 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" aria-label="Next">
          <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-hidden 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-hidden 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" aria-label="Previous">
            <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-hidden 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" aria-label="Next">
            <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-hidden 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-hidden focus-visible:outline-offset-1.5 focus-visible:outline-neutral-500 dark:focus-visible:outline-neutral-200" 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="size-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-hidden focus-visible:outline-offset-1.5 focus-visible:outline-neutral-500 dark:focus-visible:outline-neutral-200" 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="size-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-hidden focus-visible:outline-offset-1.5 focus-visible:outline-neutral-500 dark:focus-visible:outline-neutral-200" 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="size-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-hidden focus-visible:outline-offset-1.5 focus-visible:outline-neutral-500 dark:focus-visible:outline-neutral-200" 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="size-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>

A carousel configured on the Y-axis with vertical slide navigation and matching button directions.

<section class="overflow-hidden w-full max-w-xs sm:max-w-md md:max-w-lg 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"
    data-carousel-axis-value="y">
    <div class="overflow-hidden outline-hidden mx-4 sm:mx-6 md:mx-8 cursor-grab active:cursor-grabbing h-96 md:[--carousel-fade-size-y:1.25rem] md:[mask-image:linear-gradient(to_bottom,transparent,black_var(--carousel-fade-size-y),black_calc(100%-var(--carousel-fade-size-y)),transparent)] md:[-webkit-mask-image:linear-gradient(to_bottom,transparent,black_var(--carousel-fade-size-y),black_calc(100%-var(--carousel-fade-size-y)),transparent)]" data-carousel-target="viewport">
      <div class="flex flex-col h-full">
        <div class="mb-2 sm:mb-3 md:mb-4 flex-shrink-0 flex-grow-0 basis-1/2 sm:basis-2/5 min-w-0 w-[88%] mx-auto p-5 sm:p-6 md:p-7 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none flex flex-col justify-center items-center">
          <div class="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-2">Vertical Slide 1</div>
          <div class="text-sm text-neutral-600 dark:text-neutral-400">Scrolls on the Y-axis</div>
        </div>
        <div class="mb-2 sm:mb-3 md:mb-4 flex-shrink-0 flex-grow-0 basis-1/2 sm:basis-2/5 min-w-0 w-[88%] mx-auto p-5 sm:p-6 md:p-7 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none flex flex-col justify-center items-center">
          <div class="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-2">Vertical Slide 2</div>
          <div class="text-sm text-neutral-600 dark:text-neutral-400">Works for stacked content flows</div>
        </div>
        <div class="mb-2 sm:mb-3 md:mb-4 flex-shrink-0 flex-grow-0 basis-1/2 sm:basis-2/5 min-w-0 w-[88%] mx-auto p-5 sm:p-6 md:p-7 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none flex flex-col justify-center items-center">
          <div class="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-2">Vertical Slide 3</div>
          <div class="text-sm text-neutral-600 dark:text-neutral-400">Useful for timelines and steps</div>
        </div>
        <div class="mb-2 sm:mb-3 md:mb-4 flex-shrink-0 flex-grow-0 basis-1/2 sm:basis-2/5 min-w-0 w-[88%] mx-auto p-5 sm:p-6 md:p-7 text-center rounded-xl border border-neutral-200 bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700 relative select-none flex flex-col justify-center items-center">
          <div class="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-2">Vertical Slide 4</div>
          <div class="text-sm text-neutral-600 dark:text-neutral-400">Fully keyboard and touch friendly</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-hidden 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" aria-label="Previous">
          <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="M9.52999 4.71999C9.23699 4.42699 8.76199 4.42699 8.46899 4.71999L2.21999 10.97C1.92699 11.263 1.92699 11.738 2.21999 12.031C2.51299 12.324 2.988 12.324 3.281 12.031L9.001 6.311L14.721 12.031C14.867 12.177 15.059 12.251 15.251 12.251C15.443 12.251 15.635 12.178 15.781 12.031C16.074 11.738 16.074 11.263 15.781 10.97L9.531 4.71999H9.52999Z"></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-hidden 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" aria-label="Next">
          <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="M8.99999 13.5C8.80799 13.5 8.61599 13.4271 8.46999 13.2801L2.21999 7.03005C1.92699 6.73705 1.92699 6.26202 2.21999 5.96902C2.51299 5.67602 2.98799 5.67602 3.28099 5.96902L9.00099 11.689L14.721 5.96902C15.014 5.67602 15.489 5.67602 15.782 5.96902C16.075 6.26202 16.075 6.73705 15.782 7.03005L9.53199 13.2801C9.38599 13.4261 9.19399 13.5 9.00199 13.5H8.99999Z"></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 wheel gestures enabled for smooth navigation.

<section id="wheel-gestures-carousel" 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"
  data-carousel-wheel-gestures-value="true"
  data-carousel-drag-free-value="true">
  <div class="overflow-hidden outline-hidden mx-4 sm:mx-6 md:mx-8 cursor-grab active:cursor-grabbing md:[--carousel-fade-size:1.25rem] md:[mask-image:linear-gradient(to_right,transparent,black_var(--carousel-fade-size),black_calc(100%-var(--carousel-fade-size)),transparent)] md:[-webkit-mask-image:linear-gradient(to_right,transparent,black_var(--carousel-fade-size),black_calc(100%-var(--carousel-fade-size)),transparent)]" 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">
        <div class="space-y-2">
          <h3 class="text-lg font-semibold">Wheel Gestures Enabled</h3>
          <p class="text-sm text-neutral-600 dark:text-neutral-400">Try scrolling with your mouse wheel or trackpad to navigate!</p>
        </div>
      </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">
        <div class="space-y-2">
          <h3 class="text-lg font-semibold">Slide 2</h3>
          <p class="text-sm text-neutral-600 dark:text-neutral-400">Mouse wheel support makes navigation more intuitive</p>
        </div>
      </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">
        <div class="space-y-2">
          <h3 class="text-lg font-semibold">Slide 3</h3>
          <p class="text-sm text-neutral-600 dark:text-neutral-400">Touch scroll also works on trackpads</p>
        </div>
      </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">
        <div class="space-y-2">
          <h3 class="text-lg font-semibold">Slide 4</h3>
          <p class="text-sm text-neutral-600 dark:text-neutral-400">Perfect for desktop and mobile experiences</p>
        </div>
      </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">
        <div class="space-y-2">
          <h3 class="text-lg font-semibold">Slide 5</h3>
          <p class="text-sm text-neutral-600 dark:text-neutral-400">Enhanced user experience with natural scrolling</p>
        </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-hidden 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" aria-label="Previous">
        <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-hidden 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" aria-label="Next">
        <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>

  <!-- Instructional text -->
  <div class="mt-4 px-4 sm:px-6 md:px-8 max-w-sm mx-auto">
    <div class="text-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
      <p class="text-sm text-blue-700 dark:text-blue-300">
        <strong>💡 Try it:</strong> Use your mouse wheel or trackpad scrolling to navigate through the slides! This carousel has wheel gestures enabled.
      </p>
    </div>
  </div>
</section>

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 (horizontal carousels)
Arrow Right
Navigate to the next slide (horizontal carousels)
Arrow Up
Navigate to the previous slide (vertical carousels)
Arrow Down
Navigate to the next slide (vertical carousels)
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

Powered by

Get notified when new components come out