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.
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>
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.