Skip to content
data-slotv1.0.0
Esc
↑↓navigate↵open⌘Jpreview
On this page

Carousel

A scroll-snapping slideshow with keyboard, button, and optional drag navigation.

@data-slot/carouselSource ↗
bun add @data-slot/carousel
npm install @data-slot/carousel
pnpm add @data-slot/carousel

Anatomy

The content element is a native scroll container and every direct carousel-item child is a slide. Style the content with scroll-snap-type and size each slide to fill it; the component reads the resulting layout rather than imposing one. The previous and next controls are optional.

<div data-slot="carousel" data-default-index="0">
  <div data-slot="carousel-content">
    <div data-slot="carousel-item">Slide 1</div>
    <div data-slot="carousel-item">Slide 2</div>
    <div data-slot="carousel-item">Slide 3</div>
  </div>
  <button data-slot="carousel-previous">Previous</button>
  <button data-slot="carousel-next">Next</button>
</div>

The active slide is the one the scroll position rests on. Navigation scrolls smoothly, or instantly when the visitor prefers reduced motion, and the index follows once scrolling settles. Inactive slides are inert and aria-hidden, so the layout should show one slide at a time.

Examples

Arrow keys move between slides while focus is inside the carousel, and Home and End jump to the first and last slide. data-loop lets the controls wrap around. The dots below the slides are demo markup: each one dispatches carousel:set with its index, and they follow carousel:change to highlight the active slide.

Preview
01
02
03
04
05
Show codeHide code
<div data-slot="carousel" data-loop class="carousel">
  <div data-slot="carousel-content" class="carousel-content">
    <div data-slot="carousel-item" class="carousel-item">01</div>
    <div data-slot="carousel-item" class="carousel-item">02</div>
    <div data-slot="carousel-item" class="carousel-item">03</div>
    <div data-slot="carousel-item" class="carousel-item">04</div>
    <div data-slot="carousel-item" class="carousel-item">05</div>
  </div>

  <div class="carousel-controls">
    <div class="carousel-nav-group">
      <button data-slot="carousel-previous" class="carousel-nav" aria-label="Previous slide">
        &lt;
      </button>
      <button data-slot="carousel-next" class="carousel-nav" aria-label="Next slide">
        &gt;
      </button>
    </div>

    <div class="carousel-dots">
      <button data-slot="carousel-dot" data-state="active" class="carousel-dot" type="button" aria-label="Go to slide 1"></button>
      <button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 2"></button>
      <button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 3"></button>
      <button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 4"></button>
      <button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 5"></button>
    </div>
  </div>
</div>

<style>
  .carousel {
    width: 100%;
    max-width: 56rem;
    margin-inline: auto;
    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
    color: var(--text);
  }

  .carousel-content {
    display: flex;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scrollbar-width: none;
    border: 1px solid var(--border);
    border-radius: 0;
    background: var(--code-bg);
  }

  .carousel-content::-webkit-scrollbar {
    display: none;
  }

  .carousel-item {
    flex: 0 0 100%;
    min-width: 0;
    scroll-snap-align: start;
    height: 21rem;
    display: grid;
    place-items: center;
    color: var(--text);
    font-size: 3.75rem;
    font-weight: 500;
    line-height: 1;
    letter-spacing: 0.2em;
    text-indent: 0.2em;
  }

  .carousel-controls {
    margin-top: 1.25rem;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
  }

  .carousel-nav-group {
    display: flex;
    align-items: center;
    gap: 0.75rem;
  }

  .carousel-nav {
    display: grid;
    place-items: center;
    touch-action: manipulation;
    padding: 0;
    width: 3rem;
    height: 3rem;
    border-radius: 0;
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--text);
    font-size: 1.25rem;
    line-height: 1;
    cursor: pointer;
    transition: border-color 180ms ease, background-color 180ms ease;
  }

  .carousel-nav:hover {
    border-color: var(--text);
    background: var(--code-bg);
  }

  .carousel-nav:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .carousel-dots {
    display: flex;
    align-items: center;
    gap: 0.75rem;
  }

  .carousel-dot {
    padding: 0;
    width: 0.75rem;
    height: 0.75rem;
    border-radius: 9999px;
    border: 1px solid var(--border);
    background: var(--surface);
    cursor: pointer;
    transition: border-color 180ms ease, background-color 180ms ease;
  }

  .carousel-dot[data-state="active"] {
    border-color: var(--text);
    background: var(--text);
  }

  @media (width < 40rem) {
    .carousel-item {
      height: 14rem;
      font-size: 2.5rem;
    }

    .carousel-controls {
      margin-top: 1rem;
    }

    .carousel-nav {
      width: 2.5rem;
      height: 2.5rem;
      font-size: 1.1rem;
    }

    .carousel-dots {
      gap: 0.5rem;
    }
  }
</style>
<div data-slot="carousel" data-loop class="mx-auto w-full max-w-4xl font-mono text-[var(--text)]">
  <div
    data-slot="carousel-content"
    class="flex overflow-x-auto snap-x snap-mandatory rounded-none border border-[var(--border)] bg-[var(--code-bg)] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
  >
    <div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
      01
    </div>
    <div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
      02
    </div>
    <div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
      03
    </div>
    <div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
      04
    </div>
    <div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
      05
    </div>
  </div>

  <div class="mt-4 flex items-center justify-between gap-4 sm:mt-5">
    <div class="flex items-center gap-3">
      <button
        data-slot="carousel-previous"
        class="grid h-10 w-10 touch-manipulation place-items-center rounded-none border border-[var(--border)] bg-[var(--surface)] p-0 text-[1.1rem] leading-none cursor-pointer transition-colors duration-[180ms] ease-[ease] sm:h-12 sm:w-12 sm:text-xl sm:leading-none hover:border-[var(--text)] hover:bg-[var(--code-bg)] disabled:cursor-not-allowed disabled:opacity-45"
        aria-label="Previous slide"
      >
        &lt;
      </button>
      <button
        data-slot="carousel-next"
        class="grid h-10 w-10 touch-manipulation place-items-center rounded-none border border-[var(--border)] bg-[var(--surface)] p-0 text-[1.1rem] leading-none cursor-pointer transition-colors duration-[180ms] ease-[ease] sm:h-12 sm:w-12 sm:text-xl sm:leading-none hover:border-[var(--text)] hover:bg-[var(--code-bg)] disabled:cursor-not-allowed disabled:opacity-45"
        aria-label="Next slide"
      >
        &gt;
      </button>
    </div>

    <div class="flex items-center gap-2 sm:gap-3">
      <button data-slot="carousel-dot" data-state="active" type="button" aria-label="Go to slide 1" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
      <button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 2" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
      <button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 3" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
      <button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 4" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
      <button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 5" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
    </div>
  </div>
</div>

Drag to navigate

Add data-drag to let people drag or swipe the slides. Native scroll snapping pauses while a drag is in progress and the carousel settles on the nearest slide when the pointer lifts. Presses on links, buttons, and form controls inside a slide never start a drag.

Preview
01
02
03
04
05
Show codeHide code
<div data-slot="carousel" data-drag data-loop class="carousel">
  <div data-slot="carousel-content" class="carousel-content">
    <div data-slot="carousel-item" class="carousel-item">01</div>
    <div data-slot="carousel-item" class="carousel-item">02</div>
    <div data-slot="carousel-item" class="carousel-item">03</div>
    <div data-slot="carousel-item" class="carousel-item">04</div>
    <div data-slot="carousel-item" class="carousel-item">05</div>
  </div>

  <div class="carousel-controls">
    <div class="carousel-nav-group">
      <button data-slot="carousel-previous" class="carousel-nav" aria-label="Previous slide">
        &lt;
      </button>
      <button data-slot="carousel-next" class="carousel-nav" aria-label="Next slide">
        &gt;
      </button>
    </div>

    <div class="carousel-dots">
      <button data-slot="carousel-dot" data-state="active" class="carousel-dot" type="button" aria-label="Go to slide 1"></button>
      <button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 2"></button>
      <button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 3"></button>
      <button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 4"></button>
      <button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 5"></button>
    </div>
  </div>
</div>

<style>
  .carousel {
    width: 100%;
    max-width: 56rem;
    margin-inline: auto;
    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
    color: var(--text);
  }

  .carousel-content {
    display: flex;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scrollbar-width: none;
    border: 1px solid var(--border);
    border-radius: 0;
    background: var(--code-bg);
  }

  .carousel-content::-webkit-scrollbar {
    display: none;
  }

  .carousel-item {
    flex: 0 0 100%;
    min-width: 0;
    scroll-snap-align: start;
    height: 21rem;
    display: grid;
    place-items: center;
    color: var(--text);
    font-size: 3.75rem;
    font-weight: 500;
    line-height: 1;
    letter-spacing: 0.2em;
    text-indent: 0.2em;
  }

  .carousel-controls {
    margin-top: 1.25rem;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
  }

  .carousel-nav-group {
    display: flex;
    align-items: center;
    gap: 0.75rem;
  }

  .carousel-nav {
    display: grid;
    place-items: center;
    touch-action: manipulation;
    padding: 0;
    width: 3rem;
    height: 3rem;
    border-radius: 0;
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--text);
    font-size: 1.25rem;
    line-height: 1;
    cursor: pointer;
    transition: border-color 180ms ease, background-color 180ms ease;
  }

  .carousel-nav:hover {
    border-color: var(--text);
    background: var(--code-bg);
  }

  .carousel-nav:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .carousel-dots {
    display: flex;
    align-items: center;
    gap: 0.75rem;
  }

  .carousel-dot {
    padding: 0;
    width: 0.75rem;
    height: 0.75rem;
    border-radius: 9999px;
    border: 1px solid var(--border);
    background: var(--surface);
    cursor: pointer;
    transition: border-color 180ms ease, background-color 180ms ease;
  }

  .carousel-dot[data-state="active"] {
    border-color: var(--text);
    background: var(--text);
  }

  @media (width < 40rem) {
    .carousel-item {
      height: 14rem;
      font-size: 2.5rem;
    }

    .carousel-controls {
      margin-top: 1rem;
    }

    .carousel-nav {
      width: 2.5rem;
      height: 2.5rem;
      font-size: 1.1rem;
    }

    .carousel-dots {
      gap: 0.5rem;
    }
  }
</style>
<div data-slot="carousel" data-drag data-loop class="mx-auto w-full max-w-4xl font-mono text-[var(--text)]">
  <div
    data-slot="carousel-content"
    class="flex overflow-x-auto snap-x snap-mandatory rounded-none border border-[var(--border)] bg-[var(--code-bg)] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
  >
    <div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
      01
    </div>
    <div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
      02
    </div>
    <div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
      03
    </div>
    <div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
      04
    </div>
    <div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
      05
    </div>
  </div>

  <div class="mt-4 flex items-center justify-between gap-4 sm:mt-5">
    <div class="flex items-center gap-3">
      <button
        data-slot="carousel-previous"
        class="grid h-10 w-10 touch-manipulation place-items-center rounded-none border border-[var(--border)] bg-[var(--surface)] p-0 text-[1.1rem] leading-none cursor-pointer transition-colors duration-[180ms] ease-[ease] sm:h-12 sm:w-12 sm:text-xl sm:leading-none hover:border-[var(--text)] hover:bg-[var(--code-bg)] disabled:cursor-not-allowed disabled:opacity-45"
        aria-label="Previous slide"
      >
        &lt;
      </button>
      <button
        data-slot="carousel-next"
        class="grid h-10 w-10 touch-manipulation place-items-center rounded-none border border-[var(--border)] bg-[var(--surface)] p-0 text-[1.1rem] leading-none cursor-pointer transition-colors duration-[180ms] ease-[ease] sm:h-12 sm:w-12 sm:text-xl sm:leading-none hover:border-[var(--text)] hover:bg-[var(--code-bg)] disabled:cursor-not-allowed disabled:opacity-45"
        aria-label="Next slide"
      >
        &gt;
      </button>
    </div>

    <div class="flex items-center gap-2 sm:gap-3">
      <button data-slot="carousel-dot" data-state="active" type="button" aria-label="Go to slide 1" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
      <button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 2" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
      <button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 3" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
      <button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 4" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
      <button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 5" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
    </div>
  </div>
</div>

API reference

create(scope?)

Auto-discover and bind all carousel roots in a scope (document by default).

import { create } from "@data-slot/carousel";

const controllers = create(); // CarouselController[]

createCarousel(root, options?)

Create a controller for a specific root element.

import { createCarousel } from "@data-slot/carousel";

const carousel = createCarousel(element, {
  defaultIndex: 1,
  orientation: "horizontal",
  drag: true,
  loop: false,
  onIndexChange: (index) => console.log(index),
});

Data Attributes

JS options take precedence over data attributes.

Attribute Type Default Description
data-default-index number 0 Initial active index
data-orientation horizontal | vertical horizontal Carousel orientation
data-drag boolean false Enable pointer drag/swipe navigation
data-loop boolean false Enable soft-wrap loop navigation

Options

Option Type Default Description
defaultIndex number 0 Initial active slide index
orientation "horizontal" | "vertical" "horizontal" Axis used for keyboard navigation and scrolling
drag boolean false Enable pointer drag/swipe navigation on the scroll container
loop boolean false Enable soft-wrap for prev/next/keyboard/API navigation
onIndexChange (index: number) => void undefined Called when active slide changes

Controller

Method / Property Description
prev() Navigate to previous slide
next() Navigate to next slide
goTo(index) Navigate to specific slide
index Current active index
count Total number of slides
canScrollPrev Whether previous navigation is available
canScrollNext Whether next navigation is available
destroy() Cleanup listeners and observers

Carousel navigation uses native smooth scrolling by default for:

  • prev() / next() / goTo()
  • keyboard navigation (Arrow*, Home, End)
  • inbound carousel:set events
  • optional carousel-previous / carousel-next button clicks

When the user prefers reduced motion (prefers-reduced-motion: reduce), navigation falls back to instant scroll behavior.

When drag is enabled, the carousel also supports pointer drag/swipe gestures and snaps to the nearest slide on release.

Events

Outbound (on root)

Event Detail Description
carousel:change { index: number } Fires when active index changes

Inbound (on root)

Event Detail Description
carousel:set { index?: number, action?: "next" | "prev" } Programmatically navigate carousel
root.addEventListener("carousel:change", (event) => {
  console.log(event.detail.index);
});

root.dispatchEvent(
  new CustomEvent("carousel:set", { detail: { action: "next" } }),
);

root.dispatchEvent(
  new CustomEvent("carousel:set", { detail: { index: 2 } }),
);

Required Slots

  • carousel (root)
  • carousel-content (scroll container)
  • carousel-item (direct slide children)

Optional Slots

  • carousel-previous
  • carousel-next

Styling

The component is unstyled and relies on CSS hooks:

[data-slot="carousel"] { position: relative; }

[data-slot="carousel-content"] {
  display: flex;
  overflow: auto;
  scroll-snap-type: x mandatory;
}

[data-slot="carousel"][data-dragging="true"] {
  cursor: grabbing;
}

[data-slot="carousel-item"] {
  flex: 0 0 100%;
  scroll-snap-align: start;
}

[data-slot="carousel-item"][data-state="active"] {
  opacity: 1;
}

[data-slot="carousel-item"][data-state="inactive"] {
  opacity: 0.75;
}

For vertical carousels, switch scroll-snap-type to y mandatory and use column layout.

Accessibility

The carousel shows one slide at a time. Inactive slides are marked aria-hidden and inert, which removes them and their content from the accessibility tree and the tab order until they become active. Size slides to fill the viewport, as in the styling example above; a layout that shows several slides at once would hide visible neighbours from assistive technology.

The controller automatically sets:

  • root: role="region", aria-roledescription="carousel"
  • item: role="group", aria-roledescription="slide", aria-label="n of total"
  • item state: data-state, aria-hidden, inert
  • root drag state: data-dragging="true" during an active pointer drag
  • nav controls: disabled / aria-disabled synced to scrollability