<div class="viewport">
  <div class="carousel-frame">
    <div class="carousel">
      <ul class="scroll">
        <li class="scroll-item-outer">
          <div class="scroll-item">
            <img src="https://upload.wikimedia.org/wikipedia/commons/thumb/7/75/Cute_grey_kitten.jpg/1280px-Cute_grey_kitten.jpg" alt="Picture of a gray kitten looking at the camera" />
          </div>
        </li>
        <li class="scroll-item-outer">
          <div class="scroll-item">
            <img src="https://upload.wikimedia.org/wikipedia/commons/0/06/Kitten_in_Rizal_Park%2C_Manila.jpg" alt="Picture of a gray kitten looking at a branch"/>
          </div>
        </li>
        <li class="scroll-item-outer">
          <div class="scroll-item">
            <img src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Red_Kitten_01.jpg/1280px-Red_Kitten_01.jpg" alt="Picture of an orange kitten looking at the camera" />
          </div>
        </li>
        <li class="scroll-item-outer">
          <div class="scroll-item">
            <img src="https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/Youngkitten.JPG/1280px-Youngkitten.JPG" alt="Picture of a young kitten opening its eyes"/>
          </div>
        </li>
      </ul>
    </div>
  </div>
</div>
<ul class="indicators">
  <li class="indicator">
    <button class="indicator-button" aria-pressed="true"></button>
  </li>
  <li class="indicator">
    <button class="indicator-button"></button>
  </li>
  <li class="indicator">
    <button class="indicator-button"></button>
  </li>
  <li class="indicator">
    <button class="indicator-button"></button>
  </li>
</ul>
<footer style="margin: 20px; font-size: 0.8em;">All images are via <a href="https://commons.wikimedia.org">Wikimedia Commons</a> with a public domain or share-alike license.</footer>
:root {
  --carousel-width: 40vw;
  --carousel-height: calc(0.7 * var(--carousel-width));
  --carousel-padding: 5px;
}

@media (max-width: 479px) {
  :root {
    --carousel-width: 95vw;
  }
}

.viewport {
  width: 100%;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.carousel-frame {
  background: #fafafa;
  padding: 10px;
  border-radius: 3px;
  border: 1px solid #ddd;
  width: calc(var(--carousel-width) + (2 * var(--carousel-padding)));
  height: calc(var(--carousel-height) + (2 * var(--carousel-padding)));
}

.carousel {
  width: var(--carousel-width);
  height: var(--carousel-height);
}

.scroll {
  display: flex;
  align-items: center;
  overflow-x: auto;
  overflow-y: hidden;
  width: 100%;
  height: 100%;
  -webkit-overflow-scrolling: touch;
}

ul.scroll {
  margin: 0;
  padding: 0;
  list-style: none;
}

.scroll-item-outer {
  width: 100%;
  height: 100%
}

.scroll-item {
  width: var(--carousel-width);
  height: 100%;
}

img {
  object-fit: contain;
  width: 100%;
  height: 100%;
}

@supports (scroll-snap-align: start) {
  /* modern scroll snap points */
  .scroll {
    scroll-snap-type: x mandatory;
  }
  .scroll-item-outer {
    scroll-snap-align: center;
  }
}

@supports not (scroll-snap-align: start) {
  /* old scroll snap points spec */
  .scroll {
    -webkit-scroll-snap-type: mandatory;
            scroll-snap-type: mandatory;
    -webkit-scroll-snap-destination: 0% center;
            scroll-snap-destination: 0% center;
    -webkit-scroll-snap-points-x: repeat(100%);
            scroll-snap-points-x: repeat(100%);
  }
  
  .scroll-item-outer {
    scroll-snap-coordinate: 0 0;
  }
}

.indicators {
  display: flex;
  width: 100%;
  justify-content: center;
}

ul.indicators {
  margin: 0;
  padding: 0;
  list-style: none;
}

.indicator {
  padding: 10px;
}

.indicator-button {
  cursor: pointer;
  background: none;
  border: none;
  color: #333;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  padding: 0;
}

.indicator-button:after {
  content: '○';
  font-size: 1.4em;
  padding: 12px 15px 17px;
}

.indicator-button:hover {
  color: #666
}

.indicator-button:active {
  color: #999;
  padding: 0;
}

.indicator-button[aria-pressed="true"]:after {
  content: '●';
}
// via https://github.com/tootsuite/mastodon/blob/f59ed3a4fafab776b4eeb92f805dfe1fecc17ee3/app/javascript/mastodon/scroll.js
const easingOutQuint = (x, t, b, c, d) =>
  c * ((t = t / d - 1) * t * t * t * t + 1) + b

function smoothScrollPolyfill (node, key, target) {
  const startTime = Date.now()
  const offset = node[key]
  const gap = target - offset
  const duration = 1000
  let interrupt = false

  const step = () => {
    const elapsed = Date.now() - startTime
    const percentage = elapsed / duration

    if (interrupt) {
      return
    }

    if (percentage > 1) {
      cleanup()
      return
    }

    node[key] = easingOutQuint(0, elapsed, offset, gap, duration)
    requestAnimationFrame(step)
  }

  const cancel = () => {
    interrupt = true
    cleanup()
  }

  const cleanup = () => {
    node.removeEventListener('wheel', cancel)
    node.removeEventListener('touchstart', cancel)
  }

  node.addEventListener('wheel', cancel, { passive: true })
  node.addEventListener('touchstart', cancel, { passive: true })

  step()

  return cancel
}

function testSupportsSmoothScroll () {
  let supports = false
  try {
    let div = document.createElement('div')
    div.scrollTo({
      top: 0,
      get behavior () {
        supports = true
        return 'smooth'
      }
    })
  } catch (err) {} // Edge throws an error
  return supports
}

const hasNativeSmoothScroll = testSupportsSmoothScroll()

function smoothScroll (node, topOrLeft, horizontal) {
  if (hasNativeSmoothScroll) {
    return node.scrollTo({
      [horizontal ? 'left' : 'top']: topOrLeft,
      behavior: 'smooth'
    })
  } else {
    return smoothScrollPolyfill(node, horizontal ? 'scrollLeft' : 'scrollTop', topOrLeft)
  }
}

function debounce(func, ms) {
	let timeout
	return () => {
		clearTimeout(timeout)
		timeout = setTimeout(() => {
			timeout = null
      func()
		}, ms)
	}
}

const indicators = document.querySelectorAll('.indicator-button')
const scroller = document.querySelector('.scroll')

function setAriaLabels() {
  indicators.forEach((indicator, i) => {
    indicator.setAttribute('aria-label', `Scroll to item #${i + 1}`)
  })
}

function setAriaPressed(index) {
  indicators.forEach((indicator, i) => {
    indicator.setAttribute('aria-pressed', !!(i === index))
  })
}

indicators.forEach((indicator, i) => {
  indicator.addEventListener('click', e => {
    e.preventDefault()
    e.stopPropagation()
    setAriaPressed(i)
    const scrollLeft = Math.floor(scroller.scrollWidth * (i / 4))
    smoothScroll(scroller, scrollLeft, true)
  })
})

scroller.addEventListener('scroll', debounce(() => {
  let index = Math.round((scroller.scrollLeft / scroller.scrollWidth) * 4)
  setAriaPressed(index)
}, 200))

setAriaLabels()
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.