View as Markdown

Carousel

A carousel with motion and swipe, built on Embla Carousel.

100%
Loading...
<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

Note

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:

assets/controllers/carousel_controller.js
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 },
        });
    };
}
assets/controllers/carousel_display_controller.js
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));
    }
}
templates/components/Carousel.html.twig
{%- 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>
templates/components/Carousel/Content.html.twig
{%- 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>
templates/components/Carousel/Item.html.twig
{%- 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>
templates/components/Carousel/Next.html.twig
{%- 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>
templates/components/Carousel/Previous.html.twig
{%- 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>
templates/components/Button.html.twig
{%- 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.

100%
Loading...
<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.

100%
Loading...
<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.

100%
Loading...
<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.

100%
Loading...
<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.

100%
Loading...
<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.

100%
Loading...
<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.

100%
Loading...
<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.

100%
Loading...
{% 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

  • Carousel renders role="region" with aria-roledescription="carousel" and is focusable, so a screen reader user can reach it and be told what kind of widget it is. Set label to name it, since the default is just Carousel.
  • Each Carousel:Item carries role="group" and aria-roledescription="slide".
  • With the carousel focused, arrow keys move between slides: left and right when orientation is horizontal, up and down when it is vertical.
  • Carousel:Previous and Carousel:Next are icon-only buttons that carry a visually hidden label, controlled by their text prop. Translate it rather than dropping it.
  • Autoplay moves content without the user asking. Keep the autoplay delay long enough to read a slide, and prefer leaving it at 0 when the slides carry essential content.

API Reference

<twig:Carousel>

Prop Type Default
orientation 
'horizontal'|'vertical' 'horizontal'
align 
'start'|'center'|'end' 'center'
loop 
boolean false
options 
array []
autoplay 
int 0
label 
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 
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' 'outline'
size 
'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' 'icon-sm'
text 
string 'Next slide'

<twig:Carousel:Previous>

Prop Type Default
variant 
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' 'outline'
size 
'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' 'icon-sm'
text 
string 'Previous slide'

data-controller="carousel-display"

Value Type Default
data-carousel-display-template-value 
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.