Carousel
componentSlides in a row, on native scroll snapping: they scroll by touch, trackpad and keyboard without JavaScript; the controller adds the buttons, the arrows and, on request, the automatic play. ::scroll-button() being in one engine alone, the buttons do not rely on it.
Preview
One
Two
Code
<twig:Carousel label="Photos">
<twig:Carousel:Content>
<twig:Carousel:Item>One</twig:Carousel:Item>
<twig:Carousel:Item>Two</twig:Carousel:Item>
</twig:Carousel:Content>
<twig:Carousel:Previous />
<twig:Carousel:Next />
</twig:Carousel>
One
Two
Code
<twig:Carousel label="Photos">
<twig:Carousel:Content>
<twig:Carousel:Item>One</twig:Carousel:Item>
<twig:Carousel:Item>Two</twig:Carousel:Item>
</twig:Carousel:Content>
<twig:Carousel:Previous />
<twig:Carousel:Next />
</twig:Carousel>
Try it
Change the props; the example is rendered again by the server.
One
Two
<twig:Carousel label="Photos">
<twig:Carousel:Content>
<twig:Carousel:Item>One</twig:Carousel:Item>
<twig:Carousel:Item>Two</twig:Carousel:Item>
</twig:Carousel:Content>
<twig:Carousel:Previous />
<twig:Carousel:Next />
</twig:Carousel>
<twig:Carousel label="Photos">
<twig:Carousel:Content>
<twig:Carousel:Item>One</twig:Carousel:Item>
<twig:Carousel:Item>Two</twig:Carousel:Item>
</twig:Carousel:Content>
<twig:Carousel:Previous />
<twig:Carousel:Next />
</twig:Carousel>
Variants
orientation
The axis the slides scroll along.
One
Two
One
Two
One
Two
One
Two
align
The alignment of the selected slide inside the viewport.
One
Two
One
Two
One
Two
One
Two
One
Two
One
Two
loop
Whether the carousel wraps around after the last slide.
One
Two
One
Two
One
Two
One
Two
Installation
php bin/console ux:install carousel --kit=github.com/parler-haut-interagir-librement/ux-kit
It brings the recipes it needs: button
Props
| Prop | Type | Default | Description |
|---|---|---|---|
orientation |
'horizontal'|'vertical' |
'horizontal' |
The axis the slides scroll along. |
align |
'start'|'center'|'end' |
'center' |
The alignment of the selected slide inside the viewport. |
loop |
bool |
false |
Whether the carousel wraps around after the last slide. |
options |
array |
{} |
Extra options read by the controller, merged over align and loop. |
autoplay |
int |
0 |
The delay in milliseconds between two automatic slide changes. Zero disables autoplay. |
label |
string |
'Carousel' |
The accessible name of the carousel region. |
roleDescription |
string |
'carousel' |
What the carousel is announced as. |
pauseLabel |
string |
'Pause' |
The text of the button while the slides play. |
playLabel |
string |
'Play' |
The text of the button once paused. |
Accessibility
A named region, its slides announced as such, the texts as props. Automatic play has a pause button, holds while the carousel is hovered or focused, and never runs under prefers-reduced-motion (WCAG 2.2.2).
Files to copy
assets/controllers/carousel_controller.js
import { Controller } from '@hotwired/stimulus';
/** The direction a key moves in, along the axis of the carousel and the direction of the page; 0 for any other key. */
export function direction(key, { vertical = false, rtl = false } = {}) {
if (vertical) {
return { ArrowDown: 1, ArrowUp: -1 }[key] ?? 0;
}
return { ArrowRight: rtl ? -1 : 1, ArrowLeft: rtl ? 1 : -1 }[key] ?? 0;
}
/** Where a scroll stands: at the start, at the end, and on which of `count` slides of `size` pixels. */
export function standing(position, max, size, count) {
const distance = Math.abs(position);
return {
atStart: distance <= 1,
atEnd: distance >= max - 1,
index: size > 0 ? Math.min(count - 1, Math.max(0, Math.round(distance / size))) : 0,
};
}
/*
* The buttons, the arrows and the automatic play of a carousel whose slides are scrolled and snapped by the browser.
* Without JavaScript the buttons stay hidden and the slides still scroll. Automatic play holds while the carousel is
* hovered or holds the focus and while the page is hidden, and never runs under `prefers-reduced-motion`.
*/
export default class extends Controller {
static targets = ['viewport', 'previous', 'next', 'toggle'];
static values = {
orientation: { type: String, default: 'horizontal' },
options: { type: Object, default: {} },
autoplay: { type: Number, default: 0 },
pauseLabel: { type: String, default: '' },
playLabel: { type: String, default: '' },
};
#timer = null;
#holds = new Set();
#paused = false;
#index = null;
connect() {
for (const button of [...this.previousTargets, ...this.nextTargets]) {
button.hidden = false;
}
this.update();
if (this.#canPlay) {
for (const toggle of this.toggleTargets) {
toggle.hidden = false;
}
this.#play();
}
}
disconnect() {
this.#stop();
}
scrollPrev() {
this.#step(-1, this.#loop);
}
scrollNext() {
this.#step(1, this.#loop);
}
handleKeydown(event) {
const moved = direction(event.key, { vertical: this.#vertical, rtl: this.#rtl });
if (moved !== 0) {
event.preventDefault();
this.#step(moved, this.#loop);
}
}
update() {
const { atStart, atEnd, index } = this.#standing;
for (const button of this.previousTargets) {
button.disabled = atStart && !this.#loop;
}
for (const button of this.nextTargets) {
button.disabled = atEnd && !this.#loop;
}
if (index !== this.#index) {
this.#index = index;
this.dispatch('select', { detail: { index, count: this.#slides.length } });
}
}
hold(event) {
this.#holds.add(event.type === 'mouseenter' ? 'pointer' : 'focus');
this.#stop();
}
release(event) {
if (event.type === 'focusout' && this.element.contains(event.relatedTarget)) {
return;
}
this.#holds.delete(event.type === 'mouseleave' ? 'pointer' : 'focus');
this.#play();
}
toggleAutoplay() {
this.#paused = !this.#paused;
for (const toggle of this.toggleTargets) {
toggle.textContent = this.#paused ? this.playLabelValue : this.pauseLabelValue;
}
this.#paused ? this.#stop() : this.#play();
}
get #vertical() {
return this.orientationValue === 'vertical';
}
get #rtl() {
return getComputedStyle(this.element).direction === 'rtl';
}
get #loop() {
return Boolean(this.optionsValue.loop);
}
get #reduced() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
get #canPlay() {
return this.autoplayValue > 0 && !this.#reduced;
}
get #slides() {
return [...(this.viewportTarget.firstElementChild?.children ?? [])];
}
get #slideSize() {
const [first, second] = this.#slides;
const distance =
first && second
? Math.abs(this.#vertical ? second.offsetTop - first.offsetTop : second.offsetLeft - first.offsetLeft)
: 0;
return distance > 0
? distance
: this.#vertical
? this.viewportTarget.clientHeight
: this.viewportTarget.clientWidth;
}
get #standing() {
const viewport = this.viewportTarget;
return this.#vertical
? standing(
viewport.scrollTop,
viewport.scrollHeight - viewport.clientHeight,
this.#slideSize,
this.#slides.length,
)
: standing(
viewport.scrollLeft,
viewport.scrollWidth - viewport.clientWidth,
this.#slideSize,
this.#slides.length,
);
}
#step(moved, wrap) {
const viewport = this.viewportTarget;
const { atStart, atEnd } = this.#standing;
const behavior = this.#reduced ? 'auto' : 'smooth';
const sign = !this.#vertical && this.#rtl ? -1 : 1;
if (wrap && moved > 0 && atEnd) {
viewport.scrollTo({ top: 0, left: 0, behavior });
} else if (wrap && moved < 0 && atStart) {
viewport.scrollTo(
this.#vertical
? { top: viewport.scrollHeight, behavior }
: { left: sign * viewport.scrollWidth, behavior },
);
} else {
const distance = moved * this.#slideSize;
viewport.scrollBy(this.#vertical ? { top: distance, behavior } : { left: sign * distance, behavior });
}
}
#play() {
this.#stop();
if (this.#canPlay && !this.#paused && this.#holds.size === 0) {
this.#timer = setInterval(() => {
if (!document.hidden) {
this.#step(1, true);
}
}, this.autoplayValue);
}
}
#stop() {
clearInterval(this.#timer);
this.#timer = null;
}
}
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',
## bool Whether the carousel wraps around after the last slide.
loop = false,
## array Extra options read by the controller, 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',
## string What the carousel is announced as.
roleDescription = 'carousel',
## string The text of the button while the slides play.
pauseLabel = 'Pause',
## string The text of the button once paused.
playLabel = 'Play'
-%}
{%- set _carousel_options = {align: align, loop: loop}|merge(options) -%}
{%- set _carousel_class = 'isCarouselFrame' ~ (orientation is same as('vertical') ? ' isVertical' : '') ~ ({center: ' hasCenteredSlides', end: ' hasEndSlides'}[align] ?? '') -%}
{%- do provide('carousel.orientation', orientation) -%}
{%- do provide('carousel.loop', _carousel_options.loop) -%}
<div role="region" aria-roledescription="{{ roleDescription }}" tabindex="0" data-slot="carousel" data-orientation="{{ orientation }}" data-align="{{ align }}" data-carousel-orientation-value="{{ orientation }}" data-carousel-options-value="{{ _carousel_options|json_encode }}" data-carousel-autoplay-value="{{ autoplay }}" data-carousel-pause-label-value="{{ pauseLabel }}" data-carousel-play-label-value="{{ playLabel }}"{{ attributes.defaults({class: _carousel_class, 'aria-label': label, 'data-controller': 'carousel', 'data-action': 'keydown->carousel#handleKeydown mouseenter->carousel#hold mouseleave->carousel#release focusin->carousel#hold focusout->carousel#release'}) }}>
{##- The carousel structure, typically includes `Carousel:Content`, `Carousel:Previous` and `Carousel:Next`. -#}
{%- block content %}{% endblock -%}
{%- if autoplay > 0 %}
<twig:Button class="isCarouselToggle" variant="ghost" size="sm" hidden data-slot="carousel-toggle" data-carousel-target="toggle" data-action="click->carousel#toggleAutoplay">{{ pauseLabel }}</twig:Button>
{%- endif %}
</div>
templates/components/Carousel/Content.html.twig
<div class="isCarouselViewport" data-slot="carousel-content" data-carousel-target="viewport" data-action="scroll->carousel#update:passive"><div{{ attributes.defaults({class: 'isCarouselTrack'}) }}>
{##- The carousel slides, typically multiple `Carousel:Item` components. -#}
{%- block content %}{% endblock -%}
</div></div>
templates/components/Carousel/Item.html.twig
{%- props
## string What the slide is announced as.
roleDescription = 'slide'
-%}
<div role="group" aria-roledescription="{{ roleDescription }}" data-slot="carousel-item"{{ attributes.defaults({class: 'isCarouselSlide'}) }}>
{##- 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'
-%}
<twig:Button variant="{{ variant }}" size="{{ size }}" hidden data-slot="carousel-next" data-carousel-target="next" {{ ...attributes.defaults({class: 'isCarouselNext', 'data-action': 'click->carousel#scrollNext'}) }}>
<span aria-hidden="true">›</span><span class="isVisuallyHidden">{{ 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'
-%}
<twig:Button variant="{{ variant }}" size="{{ size }}" hidden data-slot="carousel-previous" data-carousel-target="previous" {{ ...attributes.defaults({class: 'isCarouselPrevious', 'data-action': 'click->carousel#scrollPrev'}) }}>
<span aria-hidden="true">‹</span><span class="isVisuallyHidden">{{ text }}</span>
</twig:Button>