Carousel

A set of slides that scroll horizontally or vertically, with arrows, dots and keyboard control — and a plain scrollable list when JavaScript never arrives.

1
2
3
4
5

The arrows, the dots and the arrow keys all move it. So does dragging it, or a trackpad, or a touchscreen — the track is an ordinary scroll container.

Installation

uvx django_shadcn@latest add carousel

Usage

<c-carousel>
    <c-carousel.content>
        {% for photo in photos %}
            <c-carousel.item class="md:basis-1/2 lg:basis-1/3">
                <img src="{{ photo.url }}" alt="{{ photo.alt }}">
            </c-carousel.item>
        {% endfor %}
    </c-carousel.content>
    <c-carousel.previous />
    <c-carousel.next />
    <c-carousel.dots />
</c-carousel>

The arrows sit at -left-12 and -right-12, outside the track, so the carousel needs room around it — px-12 on a wrapper, or class="mx-12" on the carousel itself.

Embla is not ported

Upstream drives this with Embla. Here the track is an ordinary scroll container and CSS scroll-snap does the moving.

That is not a shortcut. It is the reason dragging, the trackpad, momentum and touch all work without a line of code, and the reason the slides are still a scrollable list when JavaScript is disabled or has not loaded yet.

Alpine only reads where the track came to rest, which is the part the arrows and the dots need in order to know what to show.

How many slides fit

Each slide is basis-full by default — one at a time. Give it a different basis at a breakpoint:

<c-carousel.item class="md:basis-1/2 lg:basis-1/3">

Use a breakpoint even for a size that never changes (md:basis-1/3 rather than basis-1/3). Without one, the two flex-basis utilities land in the same place in the stylesheet and the component's own default is the one that applies.

Vertical

orientation="vertical" turns the track and rotates the arrows. The content needs a height, since a column of slides has nothing else to scroll inside:

<c-carousel orientation="vertical">
    <c-carousel.content class="h-64">
        ...
    </c-carousel.content>
</c-carousel>
A
B
C

The orientation reaches the slides as a data attribute on the carousel rather than through the Alpine scope, so they are laid out on the first paint instead of jumping into place once Alpine starts.

Keyboard

and move the carousel whenever focus is inside it, in both orientations — the same keys upstream uses.

Anatomy

Tag What it is
<c-carousel> the region, the state, and the keyboard
<c-carousel.content> the track: the scroll container and the layout
<c-carousel.item> one slide
<c-carousel.previous> the arrow back
<c-carousel.next> the arrow on
<c-carousel.dots> one dot per position the track can stop at

Props

Prop Where Default Meaning
orientation <c-carousel> horizontal horizontal or vertical
label <c-carousel> Carousel names the region for a screen reader
variant the arrows outline any button variant

Driving it yourself

The carousel's scope is available to anything inside it:

<c-carousel>
    ...
    <button type="button" @click="to(0)" x-bind:disabled="!canPrevious">Back to the start</button>
    <p>Slide <span x-text="index + 1"></span> of <span x-text="count"></span></p>
</c-carousel>

count is how many positions the track can stop at, not how many slides there are. With three slides in view, the last two can never reach the left edge, so five slides are three positions. It follows the DOM, so slides swapped in by htmx are counted without telling the component anything.

A note on dots

Upstream has no dots component; its demo reads the snap list off the Embla API and draws them in the page. That list is what count is here, so <c-carousel.dots> carries them.