Get the book!
The Inclusive Components book is now available, with updated and improved content and demos.
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.
Control
In the broadest terms, any inclusive component should be:
- Clear and easy to use
- Interoperable with different inputs and outputs
- Responsive and device agnostic
- Performant
- Under the user's control
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.
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.
(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 -->
</div>
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.
Affordance
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;
}
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>
<div class="instructions">
<p id="hover">scroll for more</p>
<p id="focus">use your arrow keys for more</p>
</div>
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.
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() {
document.body.classList.add('touch')
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.)
Slides
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.
<li>
<figure>
<img src="[url]" alt="[description]">
<figcaption>[Title of artwork]</figcaption>
</figure>
</li>
Let's switch to Flexbox for layout.
[aria-label="gallery"] ul {
display: flex;
}
[aria-label="gallery"] li {
list-style: none;
flex: 0 0 100%;
}
- Just
display: flex
is all we need on the container becauseflex-wrap
defaults tonowrap
- The
100%
in theflex
shorthand is theflex-basis
, making each item take up 100% of the<ul>
container.
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;
}
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 shouldiuseacarousel.com 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.
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) {
return
}
let img = entry.target.querySelector('img')
img.setAttribute('src', img.dataset.src)
observer.unobserve(entry.target)
})
}
const observer = new IntersectionObserver(callback, observerSettings)
slides.forEach(t => observer.observe(t))
} else {
Array.prototype.forEach.call(slides, function (s) {
let img = s.querySelector('img')
img.setAttribute('src', img.getAttribute('data-src'))
})
}
- In
observerSettings
we define the outer gallery element as the root. When<li>
elements become visible within it, that's when we take action. - We feature detect with
'IntersectionObserver' in window
and just load the images straight away if not. Sorry, old browser users, but that's the best we can offer here — at least you get the content. - For each slide that intersects, we set its
src
from the dummydata-src
attribute in typical lazy loading fashion.
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='http://www.w3.org/2000/svg' /%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.
<noscript>
<img src="[url]" alt="[description]">
</noscript>
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 mere presence of the buttons makes the slider more slider-like, increasing its affordance.
- The buttons allow the user to 'snap' slides into place. No more scrolling back and forth to get the desired slide centered exactly.
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 => {
entry.target.classList.remove('visible')
if (!entry.isIntersecting) {
return
}
let img = entry.target.querySelector('img')
if (img.dataset.src) {
img.setAttribute('src', img.dataset.src)
img.removeAttribute('data-src')
}
entry.target.classList.add('visible')
})
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.
To move the correct slide fully into view when the user presses one of the buttons, we need to know just three things:
- How wide the container is
- How many slides there are
- 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">
<li>
<button id="previous" aria-label="previous">
<svg aria-hidden="true" focusable="false"><use xlink:href="#arrow-left"></use></svg>
</button>
</li>
<li>
<button id="next" aria-label="next">
<svg aria-hidden="true" focusable="false"><use xlink:href="#arrow-right"/></svg>
</button>
</li>
</ul>
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.
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.
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 => {
entry.target.classList.remove('visible')
let a = entry.target.querySelector('a')
a.setAttribute('tabindex', '-1') // (1)
if (!entry.isIntersecting) {
return
}
let img = entry.target.querySelector('img')
if (img.dataset.src) {
img.setAttribute('src', img.dataset.src)
img.removeAttribute('data-src')
}
entry.target.classList.add('visible')
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.
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:
- The code was compiled to ES5 syntax.
- A
flexbox
image scaling bug was suppressed by usingmin-width: 1px
andmin-height: 1px
on the images. - Initially,
tabindex="-1"
was set on each of the links. This is not necessary in other browsers; it is honored in theInterSectionObserver
callback on first run.
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.
Conclusion
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.
Checklist
- Use list markup to group the slides together. Then screen reader users in 'browse' mode can use list navigation shortcuts to traverse them.
- Provide a reasonable experience in HTML with CSS, then feature detect when enhancing with JavaScript.
- Don't preload content users are not likely to see. Defer until they perform an action to see it.
- Provide generous touch targets for touch users on mobile / small screens.
- If in doubt of a control's (or widget's) affordance, spell it out with instructions
- If you are a man and got past the first paragraph without being personally offended: Congratulations! You do not see men and women as competing teams.