skip to content

Carousels (or 'content sliders') are like men. They are not literally all bad — some are even helpful and considerate. But I don't trust anyone unwilling to acknowledge a glaring pattern of awfulness. Also like men, I appreciate that many of you would rather just avoid dealing with carousels, but often don't have the choice. Hence this article.

Carousels don't have to be bad, but we have a culture of making them bad. It is usually the features of carousels, rather than the underlying concept that is at fault. As with many things inclusive, the right solution is often not what you do but what you don't do in the composition of the component.

Here, we shall be creating something that fulfills the basic purpose of a carousel — to allow the traversal of content along a horizontal axis — without being too reverential about the characteristics of past implementations.


In the broadest terms, any inclusive component should be:

That last point is one I have been considering a lot lately, and it's why I added "Do not include third parties that compromise user privacy" to the inclusive web design checklist. As well as nefarious activities, users should also be protected from unexpected or unsolicited ones. This is why WCAG prescribes the 2.2.2 Pause, Stop, Hide criterion, mandating the ability to cease unwanted animations. In carousel terms, we're talking about the ability to cease the automatic cycling of content 'slides' by providing a pause or stop button.

Carousel with pause button in bottom right corner

It's something, but I don't think it's good enough. You're not truly giving control, you're relinquishing it then handing it back later. For people with vestibular disorders for whom animations can cause nausea, by the time the pause button is located, the damage will have been done.

For this reason, I believe a truly inclusive carousel is one that never moves without the user's say-so. This is why I prefer the term 'content slider' — accepting that the operative slider is the user, not a script. Carousels start and stop moving as they see fit.

Our slider will not slide except when slid. But how is sliding instigated?

Multimodal interaction

'Multimodal' means "can be operated in different ways". Supporting different modes of operation may sound like a lot of work, but browsers are multimodal by default. Unless you screw up, all interactive content can be operated by mouse, keyboard, and (where supported) touch.

By deferring to standard browser behavior, we can support multimodality in our content slider with very little effort.

Horizontal scrolling

The simplest conceivable content slider is a region containing unwrapped content laid out on a horizontal axis, traversable by scrolling the region horizontally. The declaration overflow-x: scroll does the heavy lifting.

.slider {
  overflow-x: scroll;

.slider li {
  display: inline-block;
  white-space: nowrap;

Save for some margins and borders to improve the appearance of the slider, this is a serviceable MVP (Minimum Viable Product) for mouse users. They can scroll by pulling at a visible scrollbar, or by hovering over the slider and using trackpad gestures. And that animation is smooth too, because it's the browser doing it, not a JavaScript function fired every five milliseconds.

Slider container showing horizontal scrollbar and items overflowing invisibly, ready to be moved into view.

(Where no scrollbar is visible, affordance is not so obvious. Don't worry, I'll deal with that shortly.)

Keyboard support

For mouse users on most platforms, hovering their cursor over the slider is enough to enable scrolling of the hovered element. For touch users, simply swiping left and right does the trick. This is the kind of effortless multimodality that makes the web great.

For those using the keyboard, only when the slider is focused can it be interacted with.

Under normal circumstances, most elements do not receive focus my default — only designated interactive elements such as links and <button>s. Elements that are not interactive should not be directly focusable by the user and, if they are, it is a violation of WCAG 2.4.3 Focus Order. The reason being that focus should precede activation, and if activation is not possible then why put the element in the user's hand?

To make our slider element focusable by the user, we need to add tabindex="0". Since the (focused) element will now be announced in screen readers, we ought to give it a role and label, identifying it. In the demos to follow, we'll be using the slider to show artworks, so "gallery" seems apt.

<div role="region" aria-label="gallery" tabindex="0">  
   <!-- list of gallery pictures -->

The region role is fairly generic, but is suitable for sizable areas of content and its presence ensures that aria-label is supported correctly and announced. You can't just go putting aria-label on any inert <div> or <span>.

Now that focus is attainable, the standard behavior of being able to scroll the element using the left and right arrow keys is possible. We just need a focus style to show sighted users that the slider is actionable:

[aria-label="gallery"]:focus {
  outline: 4px solid DodgerBlue;
  outline-offset: -6px; /* compensates for 2px border */

Standalone demo of the basic content slider.


There are already a couple of things that tell the user this is a slidable region: the focus style, and the fact that the right-most image is usually cut off, suggesting there is more to see.

Depending on how critical it is for users to see the hidden content, you may deem this enough — plus it keeps things terse code-wise. However, we could spell things out much more clearly. Inclusive design mantra: If in doubt, spell it out.

We can do this by creating an 'instructions' element after the slider to reveal messages depending on the state of the slider itself. For instance, we could reveal a :hover specific message of "scroll for more". The adjacent sibling combinator (+) transcribes the :hover style to the .instructions element.

#hover {
  display: none;

[aria-label="gallery"]:hover + .instructions #hover {
  display: block;

Slider with scroll for more hint in bar below the container. Arrows point in both directions.

The :focus message can be done in much the same way. But we'll also want to associate this message to the slider region for people running screen readers. Whether or not the region is of interest to any one screen reader user is irrelevant. It gives more context as to what it is for should it be appealing to them. And they know better what they're avoiding if not.

For this we can use our faithful aria-describedby property. We point it at the focus message element using its id as the value:

<div role="region" aria-label="gallery" tabindex="0" aria-describedby="focus">  
  <!-- list of gallery pictures -->
<div class="instructions">  
  <p id="hover">scroll for more</p>
  <p id="focus">use your arrow keys for more</p>

Now, when focusing the gallery slider, screen readers will announce something similar to "gallery, region, use your arrow keys for more." As a further note on multimodality, be assured that screen reader users in "browse mode" (stepping through each element) will simply enter the region and traverse through each image in turn. In other words, the slider is multimodal even for screen reader users.

Shows path of screen reader user in browse mode, into the slider and from one item to the next
The path of a screen reader user in browse mode is much the same as a keyboard user's path given linked/interactive slides. In either case, the browser/reader will slide the container to bring the focused items into view.

Hover and focus?

It's interesting sometimes what you find in testing. In my case, I noticed that when I both hovered and focused the slider, both messages appeared. Of course.

As a refinement, I discovered I could concatenate the states (:hover:focus) and reveal a message that addresses both use cases at once.

[aria-label="gallery"]:hover:focus + .instructions #hover-and-focus {
  display: block;

Using the general sibling combinator (~) I was able to make sure the other two messages were hidden (otherwise I'd see all three!):

[aria-label="gallery"]:hover:focus + .instructions #hover-and-focus ~ * {
  display: none;

Try hovering, then clicking, the slider in this demo:

Standalone version of the content slider with mouse and keyboard affordances/instructions.

Handling the touch case

So far the touch experience is poor: No instructions are provided by default and, when you start swiping, some devices show the focus message, unhelpfully referring to "arrow keys" which most likely don't exist.

Handling the touch interaction case means first detecting if the user is operating by touch.

Critically, we don't want to detect touch support at a device level, because so many devices support touch alongside other input methods. Instead, we just want to know if the user happens to be interacting by touch. This is possible by detecting a single touchstart event. Here's a tiny script (all the best scripts are!):

window.addEventListener('touchstart', function touched() {  
  window.removeEventListener('touchstart', touched, false)
}, false)

All the script does is detect an initial touchstart event, use it to add a class to the <body> element, and remove the listener. With the class in place, we can reveal a "swipe for more" message:

.touch .instructions p {
  display: none !important;

.touch .instructions #touch {
  display: block !important;

(Note: The !important markers are there because I have simplified the selectors for readability, reducing their specificity in the process.)


Depending on your use case and content, you could just stop and call the slider good here, satisfied that we have something interoperable and multimodal that only uses about 100 bytes of JavaScript. That's the advantage of choosing to make something simple, from scratch, rather than depending on a one-size-fits-all library.

But so far our slider doesn't really do "slides", which typically take up the full width of their container. If we handle this responsively, folks can admire each artwork in isolation, across different viewports. It would also be nice to be able to add some captions, so we're going to use <figure> and <figcaption> from now on.

    <img src="[url]" alt="[description]">
    <figcaption>[Title of artwork]</figcaption>

Let's switch to Flexbox for layout.

[aria-label="gallery"] ul {
  display: flex;

[aria-label="gallery"] li {
  list-style: none;
  flex: 0 0 100%;

I'm making the <figure> a flex context too, so that I can center each figure's contents along both the vertical and horizontal axes.

[aria-label="gallery"] figure {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 50vh;

That 50vh value is the only fixed(ish) dimension I am using. It's to make sure the slider has a reasonable height, but fits within the viewport. For the image and <figcaption> to always fit within the container, we make the image scale proportionately, but compensate for the predictable height of the <figcaption>. For this we can use calc:

[aria-label="gallery"] figcaption {
  padding: 0.5rem;
  font-style: italic;

[aria-label="gallery"] img {
  max-width: 100%;
  max-height: calc(100% - 2rem);
  margin-top: 2rem;

Shows how the calc function helps to compensate for the caption height and center the image in frame.

With a padding of 0.5rem, the caption text becomes approximately 2rem high. This is removed from the flexible image's height. A margin-top of 2rem then re-centers the image.

Standalone demo of the content slider with captions.

Performance and lazy loading

One of the most striking observations noted in the classic is that, of carousels that contain linked content, "1% clicked a feature. Of those, 89% were the first position." Even for auto-rotating carousels, the research shows that the number of clicks on slides proceeding the initial slide drops off dramatically.

It is entirely likely that the first image in our content slider is the only one that most readers will ever see. In which case, we should treat it as the only image, and load subsequent images if the user chooses to view them.

We can use IntersectionObserver, where supported, to load each image as each slide begins to scroll into view.

First slide is loaded. Two slides to the right, and not visible in the container are not loaded yet.

Here's the script, with notes to follow:

const slides = document.querySelectorAll('[aria-label="gallery"] li')

const observerSettings = {  
  root: document.querySelector('[aria-label="gallery"]')

if ('IntersectionObserver' in window) {  
  const callback = (slides, observer) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) {
      let img ='img')
      img.setAttribute('src', img.dataset.src)

  const observer = new IntersectionObserver(callback, observerSettings)
  slides.forEach(t => observer.observe(t))
} else {, function (s) {
    let img = s.querySelector('img')
    img.setAttribute('src', img.getAttribute('data-src'))

Note that, so users do not see broken images on particularly slow networks, we provide a placeholder image as the original src value. This takes the form of a tiny SVG data URI:

src="data:image/svg+xml,%3Csvg xmlns='' /%3E"  

No JavaScript

Currently, users with no JavaScript running are bereft of images because the data-src/src switching cannot occur. The simplest solution seems to be to provide <noscript> tags containing the images with their true src values already in place.

  <img src="[url]" alt="[description]">

Standalone demo of the content slider with lazy loading.

Since our slider is operable without JavaScript, we're pretty good. However, this only handles the 'no JavaScript' case — which is rare — and not the 'broken/failed JavaScript' case which is distressingly common. Rik Schennink has solved this problem by placing a mutationObserver in the head of the document. A demo is available for this technique, which initially swaps src to data-src and, in testing, fairly reliably prevents the fetching of the image resources on first run.

Previous and next buttons

Typical sliders have buttons on either side of them for moving backwards or forwards through the slides. This is a convention that might be worth embracing for two reasons:

The trick is in building upon the functionality we've already designed, rather than replacing it. Our buttons should be aware of and able to respond to scrolling and swiping actions that may already have taken place.

By adapting our IntersectionObserver script, we can add and remove a .visible class to our slides:

slides.forEach(entry => {'visible')
  if (!entry.isIntersecting) {
  let img ='img')
  if (img.dataset.src)  {
    img.setAttribute('src', img.dataset.src)

Not only does this mean we'll find class="visible" on any slide that's 100% in view (such as the initial slide), but in the case that the user has scrolled to a position between two slides, they'll both carry that class.

Slider in a state where two slides are partially showing. Each has the visible HTML class.

To move the correct slide fully into view when the user presses one of the buttons, we need to know just three things:

  1. How wide the container is
  2. How many slides there are
  3. Which direction the user wants to go

If two slides are partially visible and the user presses 'next', we identify the requested slide as the second of the .visible node list. We then change the container's scrollLeft value based on the following formula:

requested slide index ⨉ (container width / number of slides)

Note the size of the previous and next buttons in the following demo. Optimized for easy touch interaction without hindering the desktop experience.

Standalone demo for the content slider with previous and next buttons.

Snap points

As Michael Scharnagl‏ pointed out to me, some browsers — Safari and Firefox included — support a simple CSS method of snapping slides into place as you scroll or use your arrow keys. Since Safari doesn't support IntersectionObserver, this is one way to improve the UX for users of that browser. The following mess of proprietary and standard properties is what worked in our case.

[aria-label="gallery"] { 
  -webkit-overflow-scrolling: touch;
  -webkit-scroll-snap-type: mandatory;
  -ms-scroll-snap-type: mandatory;
  scroll-snap-type: mandatory;
  -webkit-scroll-snap-points-x: repeat(100%);
  -ms-scroll-snap-points-x: repeat(100%);
  scroll-snap-points-x: repeat(100%);

Scroll snapping is supported in the linked content demo still to come, if you're keen to try it out. Tip: the repeat(100%) part refers to the 100% width of each slide.

The button group

By placing the two buttons in a list, they are treated as grouped items and enumerated. Since <ul> implicitly supports aria-label we can provide a helpful group label of "gallery controls" to further identify the purpose of the buttons.

<ul aria-label="gallery controls">  
    <button id="previous" aria-label="previous">
      <svg aria-hidden="true" focusable="false"><use xlink:href="#arrow-left"></use></svg>
    <button id="next" aria-label="next">
      <svg aria-hidden="true" focusable="false"><use xlink:href="#arrow-right"/></svg>

Each button, of course, must have an independent label, also administered with aria-label in this case. When a screen reader user encounters the first button, they will hear something similar to "previous button, list, gallery controls, two items".

We only provide the controls if the browser supports IntesectionObserver. For browsers that don't support it, the content slider still renders and is still mouse, keyboard and touch accessible.

instructions.parentNode.insertBefore(controls, instructions.nextElementSibling)  

Loading indicators

It should be noted that, now one can press a button to instantly load a slide, there will be an obvious gap between the slide coming into view and its image loading.

One technique to handle this would be to make a visible placeholder from the image's dominant color. You may have seen this on Pinterest and the like. However, it would mean knowing the image's dimensions and our images are intrinsically responsive.

Since our layout won't 'jump' as images are loaded (because the height is set to 50vh) we could instead cheat and center a loading indicator for each slide. This would be covered and hidden by any loaded image.

Shows a centralized loading indicator and how it would be covered by the image when the image loading has completed.

Handling linked content

Focus order is currently very simple in our slider: The slider itself receives focus (for scrolling with the arrow keys), then each of the buttons is focused, in turn.

But what if the content of each slide were linked? After you focused the slider itself, the first slide would take focus, then each subsequent slide — no matter how many there are — and finally the button controls.

Not only is this a lot of focus steps to reach the buttons (or to leave the slider altogether) but we have another small problem: If the user has scrolled the region to view the third item, they would expect that item to be the one that receives focus next. Instead, the first item takes focus and the slider is slung back to the start, bringing that first item into view.

Shows scroll direction to the right opposing focus directing which must go left to retrieve the first item.

This is no disaster. In fact, items receiving focus being automatically brought into view, without JavaScript, stands us in good stead. Invisible content should never become focusable.

But where IntersectionObserver is supported and our button controls have been rendered, having only the currently visible item(s) in the focus order makes for a good enhancement. We can amend our script so that links in items that are not intersecting take tabindex="-1", making them unfocusable. See the lines commented (1) and (2) in the following.

slides.forEach(entry => {'visible')
  let a ='a')
  a.setAttribute('tabindex', '-1') // (1)
  if (!entry.isIntersecting) {
  let img ='img')
  if (img.dataset.src)  {
    img.setAttribute('src', img.dataset.src)
   a.removeAttribute('tabindex', '-1') // (2)

Simple. Now either one or two slides will gain focus, depending on how many are intersecting, and the button controls become quicker and easier to reach.

Shows two slides either side of the slider, out of view. Each has tabindex minus one.

Standalone demo of the content slider with invisible linked items removed from focus.

The complete script for this content slider is approximately 1.7KB minified. The first result when searching for 'carousel plugin' using Google search is 41.9KB minified and uses incorrect WAI-ARIA attribution, in some cases hiding focusable content from screen reader software using aria-hidden. Beware the fourth rule of ARIA use.

In this final demo, some provisions have been made for Edge and Internet Explorer:

Safari does not support IntersectionObserver yet, but a polyfill is available for about 6KB gzipped. The slider works okay in Safari and other non-supporting browsers without it.


Inclusive design is not about giving everyone the same experience. It's about give as many people as possible a decent experience. Our slider isn't the fanciest implementation out there, but that's just as well: it's the content that should be wowing people, not the interface, and Hanna Höch's epochal Dadaist photomontages are not to be upstaged. Making sure our content slider is responsive, lightweight, robust, and interoperable means a larger audience for a deserving artist.

In my conference talk Writing Less Damned Code, I introduce the concept of unprogressive non-enhancement — the idea that the flow content from which we construct tab interfaces, carousels and similar, should often be left unreconstructed. No enhancement can be better than 'enhancement'. But, when used judiciously and with care, augmented presentations of content such as content sliders can be quite compelling ways of consuming information. There just better be a good, well-researched reason to take that leap.


Back to components list