Skip to content
Learn Measure Blog Case studies About
  • Home
  • All patterns
  • Component patterns

Carousel

Jun 1, 2022

This pattern shows how to create a color-adaptive, responsive, and accessible carousel component.

Important

This pattern uses Open Props

Video on YouTube · Source on Github

<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>
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 shouldnt 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)
})
: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 }
}
Open demo
Last updated: Jun 1, 2022 — Improve article
Share
subscribe

Contribute

  • File a bug
  • View source

Related content

  • developer.chrome.com
  • Chrome updates
  • Web Fundamentals
  • Case studies
  • Podcasts
  • Shows

Connect

  • Twitter
  • YouTube
  • Google Developers
  • Chrome
  • Firebase
  • Google Cloud Platform
  • All products
  • Terms & Privacy
  • Community Guidelines

Except as otherwise noted, the content of this page is licensed under the Creative Commons Attribution 4.0 License, and code samples are licensed under the Apache 2.0 License. For details, see the Google Developers Site Policies.