Carousel
A carousel with motion and swipe, built on Embla Carousel.
<twig:Carousel class="mx-auto w-full max-w-[10rem] sm:max-w-[14rem]">
<twig:Carousel:Content>
{% for i in 1..5 %}
<twig:Carousel:Item>
<div class="p-1">
<twig:Card>
<twig:Card:Content class="flex aspect-square items-center justify-center p-6">
<span class="text-4xl font-semibold">{{ i }}</span>
</twig:Card:Content>
</twig:Card>
</div>
</twig:Carousel:Item>
{% endfor %}
</twig:Carousel:Content>
<twig:Carousel:Previous />
<twig:Carousel:Next />
</twig:Carousel>
The carousel is focusable and responds to arrow keys: Left/Right on a horizontal carousel, Up/Down on a vertical one. In RTL, the horizontal mapping is mirrored.
Installation
Available since UX Toolkit 3.5.
php bin/console ux:install carousel --kit shadcn
Install the following Composer dependencies:
composer require twig/extra-bundle twig/html-extra:^3.24.0 symfony/ux-icons symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0
Install the following front-end dependencies:
npm install embla-carousel@^8.6.0 embla-carousel-autoplay@^8.6.0
# ...or with Symfony AssetMapper
php bin/console importmap:require embla-carousel embla-carousel-autoplay
Copy the following file(s) into your app:
import { Controller } from '@hotwired/stimulus';
import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';
export default class extends Controller {
static targets = ['viewport', 'previous', 'next'];
static values = {
orientation: { type: String, default: 'horizontal' },
options: { type: Object, default: {} },
autoplay: { type: Number, default: 0 },
};
#embla = null;
#rtl = false;
connect() {
this.#rtl = 'rtl' === getComputedStyle(this.element).direction;
// `axis` and `direction` are derived from the `orientation` prop and the rendered text
// direction, which also drive the component's classes, so they always win over `options`.
this.#embla = EmblaCarousel(
this.viewportTarget,
{
...this.optionsValue,
axis: 'vertical' === this.orientationValue ? 'y' : 'x',
direction: this.#rtl ? 'rtl' : 'ltr',
},
this.autoplayValue > 0
? [Autoplay({ delay: this.autoplayValue, stopOnMouseEnter: true, stopOnInteraction: false })]
: []
);
this.#embla.on('select', this.#sync).on('reInit', this.#sync);
this.#sync();
}
disconnect() {
this.#embla?.destroy();
this.#embla = null;
}
scrollPrev() {
this.#embla?.scrollPrev();
}
scrollNext() {
this.#embla?.scrollNext();
}
handleKeydown(event) {
if (!this.#embla || event.target.closest('input, textarea, select, [contenteditable]')) {
return;
}
let forward;
if ('vertical' === this.orientationValue) {
if ('ArrowUp' === event.key) {
forward = false;
} else if ('ArrowDown' === event.key) {
forward = true;
} else {
return;
}
} else if ('ArrowLeft' === event.key) {
forward = this.#rtl;
} else if ('ArrowRight' === event.key) {
forward = !this.#rtl;
} else {
return;
}
if (forward ? !this.#embla.canScrollNext() : !this.#embla.canScrollPrev()) {
return;
}
event.preventDefault();
if (forward) {
this.scrollNext();
} else {
this.scrollPrev();
}
}
#sync = () => {
const embla = this.#embla;
this.previousTargets.forEach((button) => {
button.disabled = !embla.canScrollPrev();
});
this.nextTargets.forEach((button) => {
button.disabled = !embla.canScrollNext();
});
this.dispatch('select', {
detail: { index: embla.selectedScrollSnap(), count: embla.scrollSnapList().length },
});
};
}
import { Controller } from '@hotwired/stimulus';
/**
* @value template The output text, where `%index%` and `%count%` are replaced by the current slide number and the total number of slides.
* @target output The element whose text content displays the carousel position.
* @action update Writes the carousel's current position into the output target.
*/
export default class extends Controller {
static targets = ['output'];
static values = {
template: { type: String, default: 'Slide %index% of %count%' },
};
update(event) {
const { index, count } = event.detail;
this.outputTarget.textContent = this.templateValue
.replaceAll('%index%', String(index + 1))
.replaceAll('%count%', String(count));
}
}
{%- props
## 'horizontal'|'vertical' The axis the slides scroll along.
orientation = 'horizontal',
## 'start'|'center'|'end' The alignment of the selected slide inside the viewport.
align = 'center',
## boolean Whether the carousel wraps around after the last slide.
loop = false,
## array Extra [Embla Carousel options](https://www.embla-carousel.com/api/options/), merged over `align` and `loop`.
options = {},
## int The delay in milliseconds between two automatic slide changes. Zero disables autoplay.
autoplay = 0,
## string The accessible name of the carousel region.
label = 'Carousel'
-%}
{%- set _carousel_options = {align: align, loop: loop}|merge(options) -%}
{%- do provide('carousel.orientation', orientation) -%}
{%- do provide('carousel.loop', _carousel_options.loop) -%}
<div
role="region"
aria-roledescription="carousel"
aria-label="{{ label }}"
tabindex="0"
data-slot="carousel"
data-orientation="{{ orientation }}"
data-carousel-orientation-value="{{ orientation }}"
data-carousel-options-value="{{ _carousel_options|json_encode }}"
data-carousel-autoplay-value="{{ autoplay }}"
{{ attributes.defaults({
class: 'relative outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50'|tailwind_classes,
'data-controller': 'carousel',
'data-action': 'keydown->carousel#handleKeydown'|html_attr_type('sst'),
}) }}
>
{##- The carousel structure, typically includes `Carousel:Content`, `Carousel:Previous` and `Carousel:Next`. -#}
{%- block content %}{% endblock -%}
</div>
{%- set _carousel_orientation = inject('carousel.orientation', 'horizontal') -%}
<div
data-slot="carousel-content"
data-carousel-target="viewport"
class="overflow-hidden {{ _carousel_orientation == 'vertical' ? 'touch-pan-x' : 'touch-pan-y' }}"
>
<div
{{ attributes.defaults({
class: ('flex ' ~ (_carousel_orientation == 'vertical' ? '-mt-4 flex-col' : '-ms-4'))|tailwind_classes,
}) }}
>
{##- The carousel slides, typically multiple `Carousel:Item` components. -#}
{%- block content %}{% endblock -%}
</div>
</div>
{%- set _carousel_orientation = inject('carousel.orientation', 'horizontal') -%}
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
{{ attributes.defaults({
class: ('min-w-0 shrink-0 grow-0 basis-full ' ~ (_carousel_orientation == 'vertical' ? 'pt-4' : 'ps-4'))|tailwind_classes,
}) }}
>
{##- The slide content. -#}
{%- block content %}{% endblock -%}
</div>
{%- props
## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The button style variant.
variant = 'outline',
## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
size = 'icon-sm',
## string The accessible label of the button.
text = 'Next slide'
-%}
{%- set _carousel_orientation = inject('carousel.orientation', 'horizontal') -%}
<twig:Button
variant="{{ variant }}"
size="{{ size }}"
data-slot="carousel-next"
data-carousel-target="next"
{{ ...attributes.defaults({
class: ('absolute touch-manipulation ' ~ (_carousel_orientation == 'vertical' ? '-bottom-12 start-1/2 -translate-x-1/2 rtl:translate-x-1/2 rotate-90' : 'inset-y-0 -end-12 my-auto'))|tailwind_classes,
'data-action': 'click->carousel#scrollNext'|html_attr_type('sst'),
}) }}
>
<twig:ux:icon name="lucide:chevron-right" class="{{ _carousel_orientation == 'vertical' ? '' : 'rtl:rotate-180' }}" />
<span class="sr-only">{{ text }}</span>
</twig:Button>
{%- props
## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The button style variant.
variant = 'outline',
## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
size = 'icon-sm',
## string The accessible label of the button.
text = 'Previous slide'
-%}
{%- set _carousel_orientation = inject('carousel.orientation', 'horizontal') -%}
{%- set _carousel_loop = inject('carousel.loop', false) -%}
<twig:Button
variant="{{ variant }}"
size="{{ size }}"
data-slot="carousel-previous"
data-carousel-target="previous"
{{ ...attributes.defaults({
class: ('absolute touch-manipulation ' ~ (_carousel_orientation == 'vertical' ? '-top-12 start-1/2 -translate-x-1/2 rtl:translate-x-1/2 rotate-90' : 'inset-y-0 -start-12 my-auto'))|tailwind_classes,
'data-action': 'click->carousel#scrollPrev'|html_attr_type('sst'),
disabled: not _carousel_loop,
}) }}
>
<twig:ux:icon name="lucide:chevron-left" class="{{ _carousel_orientation == 'vertical' ? '' : 'rtl:rotate-180' }}" />
<span class="sr-only">{{ text }}</span>
</twig:Button>
{%- props
## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
variant = 'default',
## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
size = 'default',
## 'button'|'a' The HTML tag to render; use `a` when the button navigates.
as = 'button'
-%}
{%- set style = html_cva(
base: "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
icon: 'size-8',
'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
) -%}
<{{ as }}
data-slot="button"
data-size="{{ size }}"
data-variant="{{ variant }}"
{{ attributes.defaults({
class: style.apply({variant: variant, size: size})|tailwind_classes,
type: as == 'button' ? 'button' : false,
}) }}
>
{##- The button label and/or icon. -#}
{%- block content %}{% endblock -%}
</{{ as }}>
Usage
<twig:Carousel orientation="horizontal | vertical" align="start | center | end" loop>
<twig:Carousel:Content>
<twig:Carousel:Item>...</twig:Carousel:Item>
<twig:Carousel:Item>...</twig:Carousel:Item>
<twig:Carousel:Item>...</twig:Carousel:Item>
</twig:Carousel:Content>
<twig:Carousel:Previous />
<twig:Carousel:Next />
</twig:Carousel>
Examples
Sizes
Set slide size with a basis-* utility on Carousel:Item.
<twig:Carousel align="start" class="mx-auto w-full max-w-[12rem] sm:max-w-xs md:max-w-sm">
<twig:Carousel:Content>
{% for i in 1..5 %}
<twig:Carousel:Item class="basis-1/3">
<div class="p-1">
<twig:Card>
<twig:Card:Content class="flex aspect-square items-center justify-center p-6">
<span class="text-3xl font-semibold">{{ i }}</span>
</twig:Card:Content>
</twig:Card>
</div>
</twig:Carousel:Item>
{% endfor %}
</twig:Carousel:Content>
<twig:Carousel:Previous />
<twig:Carousel:Next />
</twig:Carousel>
Spacing
Set spacing between slides with a ps-* utility on Carousel:Item, matched by a negative -ms-* on Carousel:Content.
<twig:Carousel class="mx-auto w-full max-w-[12rem] sm:max-w-xs md:max-w-sm">
<twig:Carousel:Content class="-ms-1">
{% for i in 1..5 %}
<twig:Carousel:Item class="basis-1/3 ps-1">
<div class="p-1">
<twig:Card>
<twig:Card:Content class="flex aspect-square items-center justify-center p-6">
<span class="text-2xl font-semibold">{{ i }}</span>
</twig:Card:Content>
</twig:Card>
</div>
</twig:Carousel:Item>
{% endfor %}
</twig:Carousel:Content>
<twig:Carousel:Previous />
<twig:Carousel:Next />
</twig:Carousel>
Orientation
The orientation prop sets the axis the slides scroll along. A vertical carousel needs an explicit height on Carousel:Content.
<div class="w-full py-16">
<twig:Carousel orientation="vertical" align="start" class="mx-auto w-full max-w-xs">
<twig:Carousel:Content class="-mt-1 h-[270px]">
{% for i in 1..5 %}
<twig:Carousel:Item class="basis-1/2 pt-1">
<div class="p-1">
<twig:Card>
<twig:Card:Content class="flex items-center justify-center p-6">
<span class="text-3xl font-semibold">{{ i }}</span>
</twig:Card:Content>
</twig:Card>
</div>
</twig:Carousel:Item>
{% endfor %}
</twig:Carousel:Content>
<twig:Carousel:Previous />
<twig:Carousel:Next />
</twig:Carousel>
</div>
Options
Use the align prop to set where the selected slide rests inside the viewport, that's the only difference between the three carousels below. Each one also passes containScroll: false through options, which forwards any Embla Carousel option: without it, Embla trims the empty space at both ends and every alignment lands on the same position.
<div class="mx-auto flex w-full max-w-[10rem] flex-col gap-8 sm:max-w-xs">
{% for alignment in ['start', 'center', 'end'] %}
<div>
<p class="mb-2 text-center text-sm text-muted-foreground">align="{{ alignment }}"</p>
<twig:Carousel align="{{ alignment }}" :options="{containScroll: false}">
<twig:Carousel:Content>
{% for i in 1..5 %}
<twig:Carousel:Item class="basis-1/3">
<div class="p-1">
<twig:Card>
<twig:Card:Content class="flex aspect-square items-center justify-center p-2">
<span class="text-xl font-semibold">{{ i }}</span>
</twig:Card:Content>
</twig:Card>
</div>
</twig:Carousel:Item>
{% endfor %}
</twig:Carousel:Content>
<twig:Carousel:Previous />
<twig:Carousel:Next />
</twig:Carousel>
</div>
{% endfor %}
</div>
Loop
The loop prop wraps around after the last slide, so Carousel:Previous stays enabled on the first one and Carousel:Next on the last.
<twig:Carousel align="start" loop class="mx-auto w-full max-w-[12rem] sm:max-w-xs md:max-w-sm">
<twig:Carousel:Content>
{% for i in 1..5 %}
<twig:Carousel:Item class="basis-1/3">
<div class="p-1">
<twig:Card>
<twig:Card:Content class="flex aspect-square items-center justify-center p-6">
<span class="text-3xl font-semibold">{{ i }}</span>
</twig:Card:Content>
</twig:Card>
</div>
</twig:Carousel:Item>
{% endfor %}
</twig:Carousel:Content>
<twig:Carousel:Previous />
<twig:Carousel:Next />
</twig:Carousel>
API
The carousel dispatches a carousel:select event on every slide change, with the slide index and count in its detail. The carousel-display controller shipped with this recipe uses it to show the current position.
<div class="mx-auto w-full max-w-[10rem] sm:max-w-[14rem]" data-controller="carousel-display" data-action="carousel:select->carousel-display#update">
<twig:Carousel>
<twig:Carousel:Content>
{% for i in 1..5 %}
<twig:Carousel:Item>
<twig:Card class="m-px">
<twig:Card:Content class="flex aspect-square items-center justify-center p-6">
<span class="text-4xl font-semibold">{{ i }}</span>
</twig:Card:Content>
</twig:Card>
</twig:Carousel:Item>
{% endfor %}
</twig:Carousel:Content>
<twig:Carousel:Previous />
<twig:Carousel:Next />
</twig:Carousel>
<div class="py-2 text-center text-sm text-muted-foreground" data-carousel-display-target="output">Slide 1 of 5</div>
</div>
Autoplay
The autoplay prop takes a delay in milliseconds, 0 disables it. It is backed by Embla's autoplay plugin, configured to pause while the pointer is over the carousel and to resume after the previous/next buttons are clicked.
<twig:Carousel autoplay="2000" loop class="mx-auto w-full max-w-[10rem] sm:max-w-[14rem]">
<twig:Carousel:Content>
{% for i in 1..5 %}
<twig:Carousel:Item>
<div class="p-1">
<twig:Card>
<twig:Card:Content class="flex aspect-square items-center justify-center p-6">
<span class="text-4xl font-semibold">{{ i }}</span>
</twig:Card:Content>
</twig:Card>
</div>
</twig:Carousel:Item>
{% endfor %}
</twig:Carousel:Content>
<twig:Carousel:Previous />
<twig:Carousel:Next />
</twig:Carousel>
RTL
To enable RTL support, set the dir="rtl" attribute on the root element.
{% set arabicNumerals = ['١', '٢', '٣', '٤', '٥'] %}
<div class="flex flex-col items-center gap-12">
{# Arabic #}
<twig:Carousel dir="rtl" class="w-full max-w-[10rem] sm:max-w-[14rem]">
<twig:Carousel:Content>
{% for i in 1..5 %}
<twig:Carousel:Item>
<div class="p-1">
<twig:Card>
<twig:Card:Content class="flex aspect-square items-center justify-center p-6">
<span class="text-4xl font-semibold">{{ arabicNumerals[i - 1] }}</span>
</twig:Card:Content>
</twig:Card>
</div>
</twig:Carousel:Item>
{% endfor %}
</twig:Carousel:Content>
<twig:Carousel:Previous text="الشريحة السابقة" />
<twig:Carousel:Next text="الشريحة التالية" />
</twig:Carousel>
{# Hebrew #}
<twig:Carousel dir="rtl" class="w-full max-w-[10rem] sm:max-w-[14rem]">
<twig:Carousel:Content>
{% for i in 1..5 %}
<twig:Carousel:Item>
<div class="p-1">
<twig:Card>
<twig:Card:Content class="flex aspect-square items-center justify-center p-6">
<span class="text-4xl font-semibold">{{ i }}</span>
</twig:Card:Content>
</twig:Card>
</div>
</twig:Carousel:Item>
{% endfor %}
</twig:Carousel:Content>
<twig:Carousel:Previous text="השקופית הקודמת" />
<twig:Carousel:Next text="השקופית הבאה" />
</twig:Carousel>
</div>
Accessibility
Carouselrendersrole="region"witharia-roledescription="carousel"and is focusable, so a screen reader user can reach it and be told what kind of widget it is. Setlabelto name it, since the default is justCarousel.- Each
Carousel:Itemcarriesrole="group"andaria-roledescription="slide". - With the carousel focused, arrow keys move between slides: left and right when
orientationishorizontal, up and down when it isvertical. Carousel:PreviousandCarousel:Nextare icon-only buttons that carry a visually hidden label, controlled by theirtextprop. Translate it rather than dropping it.- Autoplay moves content without the user asking. Keep the
autoplaydelay long enough to read a slide, and prefer leaving it at0when the slides carry essential content.
API Reference
<twig:Carousel>
| Prop | Type | Default |
|---|---|---|
orientation The axis the slides scroll along.
|
'horizontal'|'vertical' |
'horizontal' |
align The alignment of the selected slide inside the viewport.
|
'start'|'center'|'end' |
'center' |
loop Whether the carousel wraps around after the last slide.
|
boolean |
false |
options Extra Embla Carousel options, merged over
align and loop. |
array |
[] |
autoplay The delay in milliseconds between two automatic slide changes. Zero disables autoplay.
|
int |
0 |
label The accessible name of the carousel region.
|
string |
'Carousel' |
| Block | Description |
|---|---|
content |
The carousel structure, typically includes Carousel:Content, Carousel:Previous and Carousel:Next. |
<twig:Carousel:Content>
| Block | Description |
|---|---|
content |
The carousel slides, typically multiple Carousel:Item components. |
<twig:Carousel:Item>
| Block | Description |
|---|---|
content |
The slide content. |
<twig:Carousel:Next>
| Prop | Type | Default |
|---|---|---|
variant The button style variant.
|
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' |
'outline' |
size The button size.
|
'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' |
'icon-sm' |
text The accessible label of the button.
|
string |
'Next slide' |
<twig:Carousel:Previous>
| Prop | Type | Default |
|---|---|---|
variant The button style variant.
|
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' |
'outline' |
size The button size.
|
'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' |
'icon-sm' |
text The accessible label of the button.
|
string |
'Previous slide' |
data-controller="carousel-display"
| Value | Type | Default |
|---|---|---|
data-carousel-display-template-value The output text, where
%index% and %count% are replaced by the current slide number and the total number of slides. |
String |
'Slide %index% of %count%' |
| Target | Description |
|---|---|
output |
The element whose text content displays the carousel position. |
| Action | Description |
|---|---|
update |
Writes the carousel's current position into the output target. |