This pattern shows how to create a color-adaptive, responsive, and accessible carousel component.
Video on YouTube · Source on Github
HTML
<div class="gui-carousel" carousel-pagination="dots" carousel-controls="auto" carousel-scrollbar="auto"
carousel-snapstop="auto" aria-label="Featured Items Carousel">
<div class="gui-carousel--scroller">
<div class="gui-carousel--snap">
<figure class="animate-visibility captioned-image">
<img loading="lazy" width="1280" height="720" src="https://picsum.photos/seed/this/1280/720.webp"
alt="Blue ocean with a large wave">
<figcaption>
<a href="#">Learn more about large ocean waves</a>
</figcaption>
</figure>
</div>
<div class="gui-carousel--snap">
<figure class="animate-visibility captioned-image">
<img loading="lazy" width="1280" height="720" src="https://picsum.photos/seed/is/1280/720.webp"
alt="Frosty orange desert sunset">
<figcaption>
<a href="#">Learn more about warm deserts</a>
</figcaption>
</figure>
</div>
<div class="gui-carousel--snap">
<figure class="animate-visibility captioned-image">
<img loading="lazy" width="1280" height="720" src="https://picsum.photos/seed/a/1280/720.webp"
alt="African sahara with a giraffe">
<figcaption>
<a href="#">Learn more about giraffe's</a>
</figcaption>
</figure>
</div>
</div>
</div>
CSS
:where(.gui-carousel) {
--_carousel-item-size: 80%;
--_carousel-gutters: max(4rem, calc((100% - var(--_carousel-item-size)) / 2));
--_carousel-scrollbar-gutter: var(--size-6);
--_carousel-pagination-size: var(--size-8);
display: grid;
grid-template-columns: [carousel-gutter] var(--_carousel-gutters) 1fr [carousel-gutter] var(--_carousel-gutters);
grid-template-rows:
[carousel-scroller] 1fr
[carousel-pagination] var(--_carousel-pagination-size);
&:focus-visible {
outline-offset: -5px;
}
/* configuration handlers */
&[carousel-pagination="gallery"] {
--_carousel-pagination-size: var(--size-10);
& > .gui-carousel--pagination {
-webkit-mask-image: linear-gradient(to right, #0000 0%, #000 5%, 95%, #000, #0000);
}
}
&[carousel-pagination="none"] {
grid-template-rows: [carousel-scroller] 1fr;
& > .gui-carousel--pagination {
display: none;
}
}
&[carousel-controls="none"] {
grid-template-columns: 0 1fr 0;
& > .gui-carousel--controls {
display: none;
}
}
&[carousel-scrollbar="none"] {
--_carousel-pagination-size: var(--size-5);
& > .gui-carousel--scroller {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
& > .gui-carousel--pagination {
place-self: start center;
}
}
&[carousel-snapstop="always"] .gui-carousel--snap {
scroll-snap-stop: always;
}
}
:where(.gui-carousel--scroller) {
grid-row: 1;
grid-column: 1/-1;
display: grid;
grid-auto-columns: 100%;
grid-auto-flow: column;
align-items: center;
gap: var(--_carousel-gutters);
padding-block: var(--size-2) var(--_carousel-scrollbar-gutter);
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
scroll-padding-inline: var(--_carousel-gutters);
padding-inline: var(--_carousel-gutters);
@media (--motionOK) {
scroll-behavior: smooth;
}
}
:where(.gui-carousel--snap) {
scroll-snap-align: center;
}
:where(.gui-carousel--controls) {
display: flex;
justify-content: space-between;
padding-inline: var(--_carousel-gutters);
display: contents;
& > .gui-carousel--control {
margin-block-end: var(--_carousel-scrollbar-gutter);
&:not([disabled="true"]):active {
transform: scale(.95);
}
}
}
:where(.gui-carousel--control) {
--_shadow-size: 0;
--_shadow-highlight-light: hsl(0 0% 50% / 10%);
--_shadow-highlight-dark: hsl(0 0% 100% / 20%);
--_shadow-highlight: var(--_shadow-highlight-light);
grid-row: 1;
place-self: center;
background: var(--surface-1);
color: var(--text-2);
inline-size: var(--size-8);
aspect-ratio: var(--ratio-square);
border-radius: var(--radius-round);
box-shadow: 0 0 0 var(--_shadow-size) var(--_shadow-highlight);
border: var(--border-size-1) solid transparent;
text-indent: 10ch;
padding: 0;
overflow: hidden;
z-index: var(--layer-1);
transition: opacity .5s var(--ease-2) .5s;
@media (--motionOK) {
transition:
opacity .5s var(--ease-2) .5s,
transform .2s var(--ease-4),
box-shadow .2s var(--ease-4),
outline-offset 145ms var(--ease-2)
;
}
@media (--OSdark) {
--_shadow-highlight: var(--_shadow-highlight-dark);
}
&:hover {
--_shadow-size: 6px;
}
&.--previous {
grid-column: 1;
}
&.--next {
grid-column: 3;
}
@nest [dir="rtl"] & > svg {
transform: rotateY(180deg);
}
&[disabled] {
cursor: not-allowed;
transition-delay: 0s;
& > svg {
opacity: .25;
}
}
&:not([disabled]):is(:hover, :focus-visible) {
color: var(--link);
}
&:not([disabled]) svg > path {
@media (--motionOK) {
--_transform: translateX(var(--_x)) scale(.95);
transition: transform .5s var(--ease-squish-3);
transform-origin: center center;
}
}
&[aria-label="Next Item"]:not([disabled]):is(:hover, :focus-visible) svg > path {
--_x: 2px;
transform: var(--_transform);
}
&[aria-label="Previous Item"]:not([disabled]):is(:hover, :focus-visible) svg > path {
--_x: -2px;
transform: var(--_transform);
}
}
:where(.gui-carousel--pagination) {
grid-column: 1/-1;
place-self: center;
display: grid;
grid-auto-flow: column;
gap: var(--size-2);
max-inline-size: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
padding-block: var(--size-2);
padding-inline: var(--size-4);
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
@media (--motionOK) {
scroll-behavior: smooth;
}
@nest [carousel-pagination="gallery"] & {
margin-block-end: 0;
}
& > [aria-selected="true"] {
background: var(--link);
}
& > [aria-selected="false"] {
transform: scale(.75);
}
& > button {
inline-size: var(--size-3);
background-color: var(--surface-4);
border: var(--border-size-1) solid transparent;
&.--gallery {
inline-size: var(--size-fluid-5);
border-radius: var(--radius-2);
border: none;
background-origin: border-box;
background-size: cover;
}
}
}
@keyframes gui-carousel--control-keypress {
0% { outline-offset: 5px }
50% { outline-offset: 0; }
}
@keyframes carousel-scrollstart {
from { scroll-snap-align: center }
to { scroll-snap-align: unset }
}
JS
import {scrollend} from 'https://cdn.jsdelivr.net/gh/argyleink/scrollyfills@latest/dist/scrollyfills.modern.js'
export default class Carousel {
constructor(element) {
this.elements = {
root: element,
scroller: element.querySelector('.gui-carousel--scroller'),
snaps: element.querySelectorAll('.gui-carousel--snap'),
previous: null, // generated in #createControl
next: null, // generated in #createControl
pagination: null, // generated in #createPagination
}
this.current = undefined // set in #initializeState
this.hasIntersected = new Set() // holds intersection results used on scrollend
this.elements.root.setAttribute('tabindex', -1)
this.elements.root.setAttribute('aria-roledescription', 'carousel')
this.elements.scroller.setAttribute('role', 'group')
this.elements.scroller.setAttribute('aria-label', 'Items Scroller')
this.elements.scroller.setAttribute('aria-live', 'Polite')
this.#createObservers()
this.#createPagination()
this.#createControls()
this.#initializeState()
this.#listen()
this.#synchronize()
}
#synchronize() {
for (let observation of this.hasIntersected) {
// toggle inert when it's not intersecting
observation.target
.toggleAttribute('inert', !observation.isIntersecting)
// toggle aria-selected on pagination dots
const dot = this.elements.pagination
.children[this.#getElementIndex(observation.target)]
dot.setAttribute('aria-selected', observation.isIntersecting)
dot.setAttribute('tabindex', !observation.isIntersecting ? '-1' : '0')
// stash the intersecting snap element
if (observation.isIntersecting) {
this.current = observation.target
this.goToElement({
scrollport: this.elements.pagination,
element: dot,
})
}
}
this.#updateControls()
this.hasIntersected.clear()
}
goNext() {
const next = this.current.nextElementSibling
if (this.current === next)
return
if (next) {
this.goToElement({
scrollport: this.elements.scroller,
element: next,
})
this.current = next
}
else {
console.log('at the end')
}
}
goPrevious() {
const previous = this.current.previousElementSibling
if (this.current === previous)
return
if (previous) {
this.goToElement({
scrollport: this.elements.scroller,
element: previous,
})
this.current = previous
}
else {
console.log('at the beginning')
}
}
goToElement({scrollport, element}) {
const dir = this.#documentDirection()
const delta = Math.abs(scrollport.offsetLeft - element.offsetLeft)
const scrollerPadding = parseInt(getComputedStyle(scrollport)['padding-left'])
const pos = scrollport.clientWidth / 2 > delta
? delta - scrollerPadding
: delta + scrollerPadding
scrollport.scrollTo(dir === 'ltr' ? pos : pos*-1, 0)
}
#updateControls() {
const {lastElementChild:last, firstElementChild:first} = this.elements.scroller
const isAtEnd = this.current === last
const isAtStart = this.current === first
// before we possibly disable a button
// shift the focus to the complimentary button
if (document.activeElement === this.elements.next && isAtEnd)
this.elements.previous.focus()
else if (document.activeElement === this.elements.previous && isAtStart)
this.elements.next.focus()
this.elements.next.toggleAttribute('disabled', isAtEnd)
this.elements.previous.toggleAttribute('disabled', isAtStart)
}
#listen() {
// observe children intersection
for (let item of this.elements.snaps)
this.carousel_observer.observe(item)
// watch document for removal of this carousel node
this.mutation_observer.observe(document, {
childList: true,
subtree: true,
})
// scrollend listener for sync
this.elements.scroller.addEventListener('scrollend', this.#synchronize.bind(this))
this.elements.next.addEventListener('click', this.goNext.bind(this))
this.elements.previous.addEventListener('click', this.goPrevious.bind(this))
this.elements.pagination.addEventListener('click', this.#handlePaginate.bind(this))
this.elements.root.addEventListener('keydown', this.#handleKeydown.bind(this))
}
#unlisten() {
for (let item of this.elements.snaps)
this.carousel_observer.unobserve(item)
this.mutation_observer.disconnect()
this.elements.scroller.removeEventListener('scrollend', this.#synchronize)
this.elements.next.removeEventListener('click', this.goNext)
this.elements.previous.removeEventListener('click', this.goPrevious)
this.elements.pagination.removeEventListener('click', this.#handlePaginate)
this.elements.root.removeEventListener('keydown', this.#handleKeydown)
}
#createObservers() {
this.carousel_observer = new IntersectionObserver(observations => {
for (let observation of observations) {
this.hasIntersected.add(observation)
// toggle --in-view class if intersecting or not
observation.target.classList
.toggle('--in-view', observation.isIntersecting)
}
}, {
root: this.elements.scroller,
threshold: .6,
})
this.mutation_observer = new MutationObserver((mutationList, observer) => {
mutationList
.filter(x => x.removedNodes.length > 0)
.forEach(mutation => {
[...mutation.removedNodes]
.filter(x => x.querySelector('.gui-carousel') === this.elements.root)
.forEach(removedEl => {
this.#unlisten()
})
})
})
}
#initializeState() {
const startIndex = this.elements.root.hasAttribute('carousel-start')
? this.elements.root.getAttribute('carousel-start') - 1
: 0
this.current = this.elements.snaps[startIndex]
this.#handleScrollStart()
// each snap target needs a marker for pagination
// each snap needs some a11y love
this.elements.snaps.forEach((snapChild, index) => {
this.hasIntersected.add({
isIntersecting: index === 0,
target: snapChild,
})
this.elements.pagination
.appendChild(this.#createMarker(snapChild, index))
snapChild.setAttribute('aria-label', `${index+1} of ${this.elements.snaps.length}`)
snapChild.setAttribute('aria-roledescription', 'item')
})
}
#handleScrollStart() {
if (this.elements.root.hasAttribute('carousel-start')) {
const itemIndex = this.elements.root.getAttribute('carousel-start')
const startElement = this.elements.snaps[itemIndex - 1]
this.elements.snaps.forEach(snap =>
snap.style.scrollSnapAlign = 'unset')
startElement.style.scrollSnapAlign = null
startElement.style.animation = 'carousel-scrollstart 1ms'
startElement.addEventListener('animationend', e => {
startElement.animation = null
this.elements.snaps.forEach(snap =>
snap.style.scrollSnapAlign = null)
}, {once: true})
}
}
#handlePaginate(e) {
if (e.target.classList.contains('gui-carousel--pagination'))
return
e.target.setAttribute('aria-selected', true)
const item = this.elements.snaps[this.#getElementIndex(e.target)]
this.goToElement({
scrollport: this.elements.scroller,
element: item,
})
}
#handleKeydown(e) {
const dir = this.#documentDirection()
const idx = this.#getElementIndex(e.target)
switch (e.key) {
case 'ArrowRight':
e.preventDefault()
const next_offset = dir === 'ltr' ? 1 : -1
const next_control = dir === 'ltr' ? this.elements.next : this.elements.previous
if (e.target.closest('.gui-carousel--pagination'))
this.elements
.pagination.children[idx + next_offset]
?.focus()
else {
if (document.activeElement === next_control)
this.#keypressAnimation(next_control)
next_control.focus()
}
dir === 'ltr' ? this.goNext() : this.goPrevious()
break
case 'ArrowLeft':
e.preventDefault()
const previous_offset = dir === 'ltr' ? -1 : 1
const previous_control = dir === 'ltr' ? this.elements.previous : this.elements.next
if (e.target.closest('.gui-carousel--pagination'))
this.elements
.pagination.children[idx + previous_offset]
?.focus()
else {
if (document.activeElement === previous_control)
this.#keypressAnimation(previous_control)
previous_control.focus()
}
dir === 'ltr' ? this.goPrevious() : this.goNext()
break
}
}
#getElementIndex(element) {
let index = 0
while (element = element.previousElementSibling)
index++
return index
}
#createPagination() {
let nav = document.createElement('nav')
nav.className = 'gui-carousel--pagination'
this.elements.root.appendChild(nav)
this.elements.pagination = nav
}
#createMarker(item, index) {
const markerType = this.elements.root.getAttribute('carousel-pagination')
index++ // user facing index shouldn't start at 0
if (markerType == 'gallery')
return this.#createMarkerGallery({index, type: markerType, item})
else
return this.#createMarkerDot({index, type: markerType, item})
}
#createMarkerDot({index, type, item}) {
const marker = document.createElement('button')
const img = item.querySelector('img')
const caption = item.querySelector('figcaption')
marker.className = 'gui-carousel--control'
marker.type = 'button'
marker.role = 'tab'
marker.title = `Item ${index}: ${img?.alt || caption?.innerText}`
marker.setAttribute('aria-label', img?.alt || caption?.innerText)
marker.setAttribute('aria-setsize', this.elements.snaps.length)
marker.setAttribute('aria-posinset', index)
marker.setAttribute('aria-controls', `carousel-item-${index}`)
return marker
}
#createMarkerGallery({index, type, item}) {
const marker = document.createElement('button')
const img = item.querySelector('img')
marker.style.backgroundImage = `url(${img.src})`
marker.className = 'gui-carousel--control --gallery'
marker.type = 'button'
marker.role = 'tab'
marker.title = `Item ${index}: ${img.alt}`
marker.setAttribute('aria-label', img.alt)
marker.setAttribute('aria-setsize', this.elements.snaps.length)
marker.setAttribute('aria-posinset', index)
marker.setAttribute('aria-controls', `carousel-item-${index}`)
return marker
}
#createControls() {
let controls = document.createElement('div')
controls.className = 'gui-carousel--controls'
let prevBtn = this.#createControl('previous')
let nextBtn = this.#createControl('next')
controls.appendChild(prevBtn)
controls.appendChild(nextBtn)
this.elements.previous = prevBtn
this.elements.next = nextBtn
this.elements.root.prepend(controls)
}
#createControl(btnType) {
let control = document.createElement('button')
let userFacingText = `${btnType.charAt(0).toUpperCase() + btnType.slice(1)} Item`
control.type = 'button'
control.title = userFacingText
control.className = `gui-carousel--control --${btnType}`
control.setAttribute('aria-controls', 'gui-carousel--controls')
control.setAttribute('aria-label', userFacingText)
let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('aria-hidden', 'true')
svg.setAttribute('viewBox', '0 0 20 20')
svg.setAttribute('fill', 'currentColor')
let path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('fill-rule', 'evenodd')
path.setAttribute('clip-rule', 'evenodd')
let previousPath = 'M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z'
let nextPath = 'M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z'
path.setAttribute('d', btnType === 'next' ? nextPath : previousPath)
svg.appendChild(path)
control.appendChild(svg)
return control
}
#keypressAnimation(element) {
element.style.animation = 'gui-carousel--control-keypress 145ms var(--ease-2)'
element.addEventListener('animationend', e => {
element.style.animation = null
}, {once: true})
}
#documentDirection() {
return document.firstElementChild.getAttribute('dir') || 'ltr'
}
}
document.querySelectorAll('.gui-carousel').forEach(element => {
new Carousel(element)
})