Building a modern carousel with CSS scroll snap, smooth scrolling, and pinch-zoom

Recently I had some fun implementing an image carousel for Pinafore. The requirements were pretty simple: users should be able to swipe horizontally through up to 4 images, and also pinch-zoom to get a closer look.

The finished product looks like this:

 

Often when you’re building something like this, it’s tempting to use an off-the-shelf solution. The problem is that this often adds a large dependency size, or the code is inflexible, or it’s framework-specific (React, Vue, etc.), or it may not be optimized for performance and accessibility.

Come on, it’s 2019. Isn’t there a decent way to build a carousel with native browser APIs?

As it turns out, there is. My carousel implementation uses a few simple building blocks:

  1. CSS scroll snap
  2. scrollTo() with smooth behavior
  3. The <pinch-zoom> custom element

CSS scroll snap

Let’s start off with CSS scroll snap. This is what makes the scrollable element “snap” to a certain position as you scroll it.

The browser support is pretty good. The only trick is that you have to write one implementation for the modern scroll snap API (supported by Chrome and Safari), and another for the older scroll snap points API (supported by Firefox[1]).

You can detect support using @supports (scroll-snap-align: start). As usual for iOS Safari, you’ll also need to add -webkit-overflow-scrolling: touch to make the element scrollable.

But lo and behold, we now have the world’s simplest carousel implementation. It doesn’t even require JavaScript – just HTML and CSS!

Note: for best results, you may want to view the above pen in full mode.

The benefit of having all this “snapping” logic inside of CSS rather than JavaScript is that the browser is doing the heavy lifting. We don’t have to use touchmove listeners or requestAnimationFrame to try to get the pixel-perfect snapping behavior with the right animation curve – the browser handles all of it for us, in native code.

And unlike touchmove, this scroll-snapping works for any method of scrolling – touchpad, touchscreen, scrollbar, you name it.

scrollTo() with smooth scrolling

The next piece of the puzzle is that most carousels have little indicator buttons that let you navigate between the items in the list.

Screenshot of a carousel containing an image of a cat with indicator buttons below showing 1 filled circle and 3 unfilled circles

For this, we will need a little bit of JavaScript. We can use the scrollTo() API with {behavior: 'smooth'}, which tells the browser to smoothly scroll to a given offset:

function scrollToItem(itemPosition, numItems, scroller) {
  scroller.scrollTo({
    scrollLeft: Math.floor(
      scroller.scrollWidth * (itemPosition / numItems)
    ),
    behavior: 'smooth'
  })
}

The only trick here is that Safari doesn’t support smooth scroll behavior and Edge doesn’t support scrollTo() at all. But we can detect support and fall back to a JavaScript implementation, such as this one.

Here is my technique for detecting native smooth scrolling:

function testSupportsSmoothScroll () {
  var supports = false
  try {
    var div = document.createElement('div')
    div.scrollTo({
      top: 0,
      get behavior () {
        supports = true
        return 'smooth'
      }
    })
  } catch (err) {}
  return supports
}

Being careful to set aria-labels and aria-pressed states for the buttons, and adding a debounced scroll listener to update the pressed state as the user scrolls, we end up with something like this:

View in full mode

You can also add generic “go left” and “go right” buttons; the principle is the same.

Hiding the scrollbar (optional)

Now, the next piece of the puzzle is that most carousels don’t have a scrollbar, and depending on the browser and OS, you might not like how the scrollbar appears.

Also, our carousel already includes all the buttons needed to scroll left and right, so it effectively has its own scrollbar. So we can consider removing the native one.

To accomplish this, we can start with overflow-x: auto rather than overflow-x: scroll, which ensures that at least if there’s only one image (and thus no possibility of scrolling left or right), the scrollbar doesn’t show.

Beyond that, we may be tempted to add overflow-x: hidden, but this actually makes the list entirely unscrollable. Bummer.

So we can use a little hack instead. Here is some CSS to remove the scrollbar, which works in Chrome, Edge, Firefox, and Safari:

.scroll {
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.scroll::-webkit-scrollbar {
  display: none;
}

And it works! The scrollbar is gone:

View in full mode

Admittedly, though, this is a bit icky. The only standards-based CSS here is scrollbar-width, which is currently only supported by Firefox. The -webkit-scrollbar hack is for Chrome and Safari, and the -ms-overflow-style hack is for Edge/IE.

So if you don’t like vendor-specific CSS, or if you think scrollbars are better for accessibility, then you can just keep the scrollbar around. Follow your heart!

Pinch-zoom

For pinch-zooming, this is one case where I allowed myself an indulgence: I use the <pinch-zoom> element from Google Chrome Labs.

I like it because it’s extremely small (5.2kB minified) and it uses Pointer Events under the hood, meaning it supports mobile touchscreens, touchpads, touchscreen laptops, and any device that supports pinch-zooming.

However, this element isn’t totally compatible with a scrollable list, because dragging your finger left and right causes the image to move left and right, rather than scroll left and right.

 

I thought this was actually a nice touch, though, since it allows you to choose which part of the image to zoom in on. So I decided to keep it.

To make this work inside a scrollable carousel, though, I decided to add a separate mode for zooming. You have to tap the magnifying glass to enable zooming, at which point dragging your finger moves the image itself rather than the carousel.

Toggling the pinch-zoom mode was as simple as removing or adding the <pinch-zoom> element to toggle it [2]. I also decided to add some explicit “zoom in” and “zoom out” buttons for the benefit of users who don’t have a device that supports pinch-zooming.

 

Of course, I could have implemented this myself using raw Pointer Events, but <pinch-zoom> offers a small footprint, a nice API, and good browser compatibility (e.g. on iOS Safari, where Pointer Events are not supported). So it felt like a worthy addition.

Intrinsic sizing

The last piece of the puzzle (I promise!) is a way to keep the images from doing a re-layout when they load. This can lead to janky-looking reflows, especially on slow connections.

 

Assuming we know the dimensions of the images in advance, we can fix this by using the intrinsicsize attribute. Unfortunately this isn’t supported in any browser yet, but it’s coming soon to Chrome! And it’s way easier than any other (hacky) solution you may think of.

Here it is in Chrome 72 with the “experimental web platform features” flag enabled:

 

Notice that the buttons don’t jump around while the image loads. Much nicer!

Accessibility check

Looking over the WAI Carousel Concepts document, there are a few good things to keep in mind when implementing this carousel:

  1. To make the carousel more keyboard-navigable, you may add keyboard shortcuts, for instance and to navigate left and right. (Note though that a scrollable horizontal list can already be focused and scrolled with the keyboard.)
  2. Use <ul> and <li> elements instead of <div>s, so that a screen reader announces it as a list.
  3. The smooth-scrolling can be distracting or nausea-inducing for some folks, so respect prefers-reduced-motion or provide an option to turn it off.
  4. As mentioned previously, use aria-label and aria-pressed for the indicator buttons.

Compatibility check

But what about IE support? I can hear your boss screaming at you already.

If you’re one of the unfortunate souls who still has to maintain IE11 support, rest assured: a scroll-snap carousel is just a normal horizontal-scrolling list on IE. It doesn’t work exactly the same, but hey, does it need to? IE11 users probably don’t expect the best anymore.

Conclusion

So that’s it! I decided not to publish this as a library, and I’m leaving the pinch-zooming and intrinsic sizing as an exercise for the reader. I think the core building blocks are simple enough that folks really ought to just take the native APIs and run with them.

Any decisions I could bake into a library would only limit the flexibility of the carousel, and leave its users high-and-dry when they need to tweak something, because I’ve taught them how to use my library instead of the native browser API.

At some point, it’s just better to go native.

Footnotes

1. For whatever reason, I couldn’t get the old scroll snap points spec to work in Edge. Sarah Drasner apparently ran into the same issue. On the bright side, though, a horizontally scrolling list without snap points is just a regular horizontally scrolling list!

2. The first version of this blog post recommended using pointer-events: none to toggle the zoom mode on or off. It turns out that this breaks right-clicking to download an image. So it seems better to just remove or add the <pinch-zoom> element to toggle it.

17 responses to this post.

  1. “It’s 2019!” – everytime I struggle with web stuff. This compatibility issue is such a pain. Why can’t they just build on a standard? Anyway, I would have to try out this carousel myself. Thanks Nolan!

    Reply

  2. Posted by Greg Lorriman on February 10, 2019 at 7:37 PM

    Nolan, Please do write more generally as well. Your articles are very readable and I always lament that you hardly ever post since dropping pouchdb. When they come up on my feedly, I always think “Read the Tea Leaves” WTF?!?!?!?! I have to say that I don’t really have much interest in javascript, but your articles are a great read. You should do something about that ‘softskill’.

    Reply

    • Thanks, Greg! I’m trying to find the time to write more, but unfortunately have been experiencing a bit of burnout since leaving the PouchDB project. This blog post was a lot of fun, but it took me almost a whole weekend to get it right! I do appreciate your kind words, though, and hopefully I can find more motivation to write more often. 🙂

      Reply

  3. Posted by schepp on February 10, 2019 at 10:54 PM

    We did almost the same in our production news site at rp-online.de for the little image sliders in between the content.

    One correction though: it’s probably not -webkit-touch-behavior: smooth that you meant, but -webkit-overflow-scrolling: touch.

    As much as I appreciate how much is already possible, I still do wish there was a full Scroll Snapping API around this, that would

    a) emit snap events
    b) add info to scroll events saying if it was triggered by user interaction or if it’s programmatic
    c) provide methods for scrolling forward, back, as well as to the start and end
    d) provide a concept for infinite scrolling, (maybe via -moz-element)

    Reply

    • D’oh, thank you! Yes, I thought -webkit-overflow-scrolling: touch had been burned into my brain, but apparently not. 😅

      In response to your points:

      1) there aren’t “snap” events per se, but you can attach a “scroll” listener and, when debounced, it’s basically the same as “snap” events
      2) good point yes, it’s a bit awkward that when you do scrollTo(), it doesn’t provide any events or a Promise that resolves when it’s finished
      3) it’s annoying, but you can do this with math as I point out in the post 🙂
      4) a virtual/infinite list is its own topic, but it is being explored in the form of “layered APIs”

      Reply

  4. […] Building a modern carousel with CSS scroll snap, smooth scrolling, and pinch-zoom […]

    Reply

  5. […] Building a modern carousel with CSS scroll snap, smooth scrolling, and pinch-zoom […]

    Reply

  6. […] Building a modern carousel with CSS scroll snap, smooth scrolling, and pinch-zoom […]

    Reply

  7. Thank you Nolan. Fantastic work! It is amazing what is possible with just css and HTML these days

    Reply

  8. Posted by Ingrid Jacquet on March 19, 2019 at 7:17 AM

    Thank you so much for your article Nolan!
    As you suggested, I’d like to add arrow-left and arrow-right buttons, but it doesn’t work so far. Each arrow make the scroller slide only once on click, means that I cannot reach the 3rd and 4th slide. If you have some time to help me on that one that would be awesome! :-)

    Reply

  9. Small typo in the first code section:

    scoller.scrollTo({
    

    should be scroller.scrollTo({

    Good post, thanks!

    Reply

  10. […] widgets from scratch, including the feed pattern and an image carousel (which I described in a previous post), I found that the single most complicated widget to implement correctly was […]

    Reply

  11. Posted by Jonas on May 7, 2020 at 12:55 AM

    Thanks for your great tutorial – ironically, I couldn’t start your screen captures on my iphoneX/chrome ;)

    Reply

  12. I do really like this solution, but will need to avoid loading all carousel images on page load. Extending it in a way that loads images as the user navigates (scrolls) is going to add some complexity that the original concept did well to avoid, but I’d say it’s worth the performance benefits when considering the use of retina images.

    Reply

  13. Posted by Janelle Moore on June 10, 2021 at 10:48 AM

    This is a fantastic carousel. How about auto scroll?

    Reply

  14. Posted by rutchkiwi on February 20, 2024 at 4:36 AM

    thanks, this is really nice! A even more modern change from 2023, would be to use IntersectionObserver instead of the scroll event listener. This is better for performance, and simplifies the code as there is no need for the debouncing then. For example: const options = {root: scroller,rootMargin: “0px”,threshold: 0.7}; const scrollCallback = (entries) => {entries.forEach((entry, i) => {if (entry.isIntersecting) {const index = Array.from(images).indexOf(entry.target);setAriaPressed(index);}});}; const observer = new IntersectionObserver(scrollCallback, options);images.forEach(image => {observer.observe(image);});

    Reply

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.