Accessible carousels (part 1, buttons)

Posted:
Tagged with:

Intro

Most carousels are inaccessible and they're often discoraged in the accessibility community, because they're often rubbish. Let's face it, we're always going be up against poorly built components, but the same can be true of any other component, it's just that carousels are more complex than nav menus, etc

But the above being said, carousels have a place when done correctly, if we were to bring up a property search website to look for a new home to rent or buy, we'd almost certainly encounter a carousel, as it's a convenient way of stacking a bunch of images into the same discrete component. They are related to the home, showing 20, 30 or more images on a single page, which may be overwhelming for some, especially if they just want to find the property details, before they waste time deciding where they're going to be put their furniture in a house they'll never live in, as there may be something in the particulars that makes it unsuitable for them.

So, I'm just going to build a few carousel some variations of a carousel. We'll start from a standard scrolling container of images, which will only be shown where JS isn't available. We will use the new native CSS carousel functions, which has very little support and we'll take a look at some of the pros and cons of that. Finally, we'll build something that is supported across all major browsers.

Initially my idea was to make this one guide, I would have also included the carousel pattern with pips or dots as controls, unfortunately, I got myself into a bit of a tangle with Safari and then an even bigger tangle with Firefox and this has taken a little longer than I expected, it also resulted in a good chunk of extra code and explanations. So this Part 1 guide will not include the version with pips/dots, we'll take our code over to a Part 2 guide and modify it in there, over the coming weeks.

Our accessible base HTML

As always, we'll just start from some base HTML, which will be a bunch of images and a heading, which we'll then style it a little, so it's available as something somewhat carousel-like, when other features are not available.

Once we have done that, we'll gradually increase more interactivity, for when it is supported.

I'm just going to use five images, for no other reason other than five kind of seems to be the minimum number of things that would warrant the legwork of making a carousel, but that's just my own personal opinion.

When I write the code the images will point to a folder in my project, but the full code on CodePen will differ, in that I have to link to those images and load them from there, because that's how CodePen works. I'll just add those images to a folder, here on MTA.

I'm not going to use any fancy image processing, so each image will just have a single asset, we won't be calling the most appropriate size or format for the viewport or browser, because we're here for accessibility, functionality and behaviour and we'd likley have some image-processing package handle all of that for us, in the real world.

First we'll get the non-carousel stuff out of the way, as the likelihood is I'll be using CSS on some of that, so for completeness, I'll include it:

We need a way to detect if JS is enabled, so let's do that now:

<!DOCTYPE html>
<html lang="en" class="no-js">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css" referrerpolicy="no-referrer" />
<script>
document.documentElement.classList.remove('no-js');
document.documentElement.classList.add('has-js');
</script>
<title>Example accessible carousel</title>
</head>
<body>
<!-- carousel stuff -->
</body>

There's only really two points of interest, here. The rest is just the absolute minimum <head> stuff, which we would of course add to:

The above can only run when JS is avilable, so we have a convenient hook for our functionality and CSS. I also have a link to font-awesome on a CDN, because whilst I attempted to use alternative symbol icons, Firefox was being awakward.

What we will do now is is add as much of the base HTML as we can. Much of that won't be used when JS is unavailable, sure, we could add it with JS, but for simplicity's sake and reducing the size of our JS file, we'll take a few extra bytes of redundant HTML and ARIA, as long as it has zero effect on the non-JS version or we can hide it.

That does provide us with a slightly awkward base, in that if we don't have JS enabled, what options do we actually have to reduce the collection of images into something a little more manageable?

We do now have some of that functionality available in CSS, of all things. Scroll buttons that require zero JS. These are quite new, having only been available without flags for a year or so. As is always par for the course, when something is released, there are usually a couple of stragglers in the adoption process and yup, they're Firefox and Safari. Because we can do this with CSS alone, but only on on Chromium based browsers, we can take a look at using it, we can't support it where it isn't available, we can just use it where it is supported.

Where it is both not supported and there is no JS we can make it a scrollable region, then it's only going to occupy a similar amount of vertical space as a functional carousel. A scrollable region is fine for a handful of images, I'm not sure how much fun it would be for 40 or 50, images, though.

Before we go down that rabbit hole, Sara Soueidan wrote a great in-depth article where she evaluated the accessibility of the CSS only carousel pattern. It's very informative, so I highly recommend giving it a read.

The main takeaway from Sara's article is they're not great, some parts are done well, others not so well. That's cool because Sara's article was last updated over a year ago, so maybe there have been bug fixes and the final result is better now? Also, we're only actually using a part of the pattern and we're only ever going to use it where there is no JavaScript, presuming the browser supports it. That's not to say that folks who access sites without JS shouldn't get an accessible experience, they absolutely should, hence why I always start these guides with progressive enhancement in mind, everybody matters. I genuinely don't know how accessible the bits I want to use are, at this moment in time, Sara's article does lead me to believe that the bits I want should be. If they're not, I'll take a different approach, it's not a great deal of CSS and our HTML doesn't need to change, so perhaps this could be a little discovery for us all? Anyway, let's write some HTML and I'll talk you through it:

<main>
<h1 class="main__title">Example carousel</h1>

<section class="carousel" id="carousel" aria-labelledby="carouselTitle" tabindex="0">
<h2 class="carousel__title visually-hidden" id="carouselTitle">City photos</h2>
<div class="carousel__inner-wrap">
<div class="carousel__controls" hidden>
<button class="carousel__autoplay-toggle" aria-controls="carouselSlides" data-play-state="play">
<span class="visually-hidden">Pause</span>
</button>
<div class="carousel__pips"></div>
<div class="carousel__btns">
<button class="carousel__prev-btn" aria-controls="carouselSlides">
<span class="visually-hidden">Previous slide</span>
</button>
<button class="carousel__next-btn" aria-controls="carouselSlides">
<span class="visually-hidden">Next slide</span>
</button>
</div>
</div>
<ul class="carousel__slides" id="carouselSlides">
<li class="carousel__item" data-slide-num="1">
<span class="carousel__counter" id="count1"><span class="visually-hidden">Slide </span>1 of 5</span>
<img src="carousel-images/london.webp" loading="eager" alt="A shot of London at dusk, taken from the North bank of the Thames, close to Tower Bridge. The bridge and buildings are are illuminated, The Shard stands proud on the left of the photo">
<div class="carousel__slide-overlay">
<a href="https://en.wikipedia.org/wiki/London" class="carousel__cta">About London</a>
</div>
</li>
<li class="carousel__item" data-slide-num="2">
<span class="carousel__counter" id="count2"><span class="visually-hidden">Slide </span>2 of 5</span>
<img src="carousel-images/tokyo.webp" loading="eager" alt="A shot of the Shibuya Scramble Crossing at Tokyo, at night. Thousands of people are crossing the busy pedestrian crossing, getting from one side of the illuminated street to the other. Many drivers wait for an opportunity to carry on their journey">
</li>
<li class="carousel__item" data-slide-num="3">
<span class="carousel__counter" id="count3"><span class="visually-hidden">Slide </span>3 of 5</span>
<img src="carousel-images/ny.webp" loading="eager" alt="A Shot of Central Park in New York, the photo is taken from height, showing a seemingly perfect rectangle of nature, situated in the middle of large buildings and skyscrapers all around. In the distance clouds are visible in the otherwise blue sky.">
</li>
<li class="carousel__item" data-slide-num="4">
<span class="carousel__counter" id="count4"><span class="visually-hidden">Slide </span>4 of 5</span>
<img src="carousel-images/paris.webp" loading="eager" alt="A shot of the Eiffel Tower in Paris taken from the embankment of the Seine River. Wispy clouds cover the sky, creating a pink hue where the sun tries to break through. The Eiffel Tower stands tall slightly to the right of the photo's center">
</li>
<li class="carousel__item" data-slide-num="5">
<span class="carousel__counter" id="count5"><span class="visually-hidden">Slide </span>5 of 5</span>
<img src="carousel-images/singapore.webp" loading="eager" alt="A shot of Marina Bay in Singapore, the Marina Bay Sands Resort stands Center, Three tall hotel buildings with a ginarmous rooftop spanning all three buildings. The ArtScience Museum is shown to the left, a structure that resembles a white lotus flower the petals seemingly part of the structure that contain the exhibits within. The waterfront glistens with reflections of the impressive structures under the the last moments of sunlight, as dusk approaches.">
</li>
</ul>
</div>
</section>
</main>


<div class="announcements visually-hidden" aria-live="polite"></div>

A quick run through of our base markup:

A few other notes

My rationale on using tabindex

Using tabindex on our region does come with the caveat that I can only remove that attribute when JS is available, so it will be present as a tab stop on the CSS carousel. Most browsers automatically make a scrollable region a tab stop, apart from Safari, so we can't rely on browsers to add this, I'm afraid. It's one additional tab stop, it's a small trade off I'm willing to make, as I'm trying to do my best for everybody taking a balanced approach. Placing the images in a scrollable region where I have no useful CSS or any JS could be appreciated, especially if there were lots of images in that region, as it could make accessing other non-graphic information significantly easier and it could reduce both effort and cognitive load.

Some boilerplate CSS

 /* Resets, normalisation and vars */
:root {
--colour-interactive: #a5e9ce;
--colour-interactive-bg: #000000;
}

*,
*::before,
*::after
{
box-sizing: border-box;
}

*:not(dialog) {
margin: 0;
}

body {
display: flex;
flex-direction: column;
align-items: center;
padding: .75rem;
line-height: 1.5;
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
}

main {
width: 100%;
max-width: 80rem;
font-size: 1.25rem;
}

img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}

input, button, textarea, select {
font: inherit;
}

p, h1, h2, h3, h4, h5, h6 {
overflow-wrap: break-word;
}

p {
text-wrap: pretty;
}

h1, h2, h3, h4, h5, h6 {
text-wrap: balance;
}

.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}

I've just taken what I need from Josh W Comeau's Modern CSS Reset, I'm not explaining any of this, but feel free to read Josh's post. I've made some modifications, added some CSS vars and created a visually-hidden class

CSS for scrollable region

This will be a combination of CSS that applies in all situations and also the situation where a scrollable region is present:

/* Base styles */

.main__title {
font-size: 2.75rem;
text-align: center;
}

.carousel {
display: flex;
justify-content: center;
}

.carousel__inner-wrap {
width: min(100%, 150dvh);
height: auto;
aspect-ratio: 3/2;
}

/* Display as a row, add a gap, fill the viewport, remove list bullets and padding, add overflow and scroll snap */
.carousel__slides {
display: flex;
align-items: flex-start;
flex-wrap: nowrap;
gap: .75rem;
border-radius: .5rem;
padding-inline-start: 0;
list-style: none;
width: 100%;
padding-inline-start: 0;
list-style: none;
box-shadow: 2px 2px 15px 2px rgba(0,0,0,0.6);
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
}

/* Center the image in the slide, if needed, set the scroll snap area and add a background */
.carousel__item {
position: relative;
flex: 0 0 auto;
scroll-snap-align: start;
aspect-ratio: 3/2;
}

/* Set the image dimensions */
.carousel__item img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}

/* Place counter in top right */
.carousel__counter {
position: absolute;
top: 0;
right: 0;
border-radius: 0 .5rem 0 .5rem;;
padding: .25rem .75rem;
font-size: 1rem;
font-weight: bold;
color: var(--colour-interactive);
background-color: var(--colour-interactive-bg);
}

/* Center the extra content ovelay to the bottom */
.carousel__slide-overlay {
position: absolute;
bottom: .5rem;
left: 50%;
display: flex;
flex-direction: column;
align-items: center;
transform: translateX(-50%);

@media (min-width: 36em) {
bottom: 1rem;
}
}

/* If there's a CTA I'm just using a similar style and colour scheme as other controls */
.carousel__cta {
position: relative;
border: 2px solid var(--colour-interactive-bg);
border-radius: 2.5rem;
padding: .25rem .75rem;
font-size: 1rem;
font-weight: bold;
text-decoration: none;
color: var(--colour-interactive-bg);
background-color: var(--colour-interactive);

&:focus-visible {
border-color: var(--colour-interactive);
background-color: var(--colour-interactive-bg);
color: var(--colour-interactive);
outline-color: transparent;
}

&:focus-visible::after {
content: "";
position: absolute;
inset: -4px;
border: 2px solid var(--colour-interactive-bg);
border-radius: 3rem;
box-shadow: 0 0 0 2px var(--colour-interactive);
}
}

@media (min-width: 36em) {
.carousel__inner-wrap {
width: min(100%, 150dvh);
}
}

@supports not selector(::scroll-marker-group) {
.no-js {
.carousel__item {
max-width: 100%;
}

.carousel__counter {
border-radius: 0 0 0 .5rem;
}
}
}

I've added brief comments to each block, as I can't go through all of this, as the CSS file will eventually be quite lengthy. I use a 3:2 aspect ratio, for no other reason than that's what I went with. Well, actually it was because 16:9 is shorter than it is wider and I wanted to increase the height on mobile, as mobile carousels are a little of a challenge, at the best of times.

We're using scroll snapping so each image snaps into view and we ensure that no image can exceed the width of the viewport. Scroll snapping does work by dragging the scrollbar, swiping on a touch display or using arrow keys on the keyboard, so we always get that snapping behaviour, where an image is always aligned to the viewport.

Just as it's nice to have receipts, the following image shows what I have done so far and I have set the browser width to 1280px, height to 1024px and then zoomed 400%, as required by Reflow.

Screenshot of the viewport in Reflow, 320 x 256px, demonstrating the image does fit in the viewport

I say attempting, not because it is beyond me, just I genuinely went into this having only read Sara's article, I've never attempted this and we'll discuss whether it's accessible enough to use or not, later.

Whilst you could absolutely learn everything you need to know, fram Sara's article, I was just picking individual bits from the features, I didn't want the pips, just the buttons, which Sara didn't raise a great deal of concern with.

The relevant CSS carousel styles

/* CSS carousel */

@supports selector(::scroll-marker-group) {
.no-js {
.carousel__inner-wrap {
position: relative;
}

.carousel__slides {
overflow-x: hidden;

&::scroll-button(*) {
position: absolute;
bottom: 1.5rem;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--colour-interactive);
border-radius: 50%;
padding: .5rem;
height: 2.5rem;
width: 2.5rem;
font-size: 1.75rem;
background-color: var(--colour-interactive-bg);
color: var(--colour-interactive);
z-index: 10;
cursor: pointer;
}

&::scroll-button(*):focus-visible {
background-color: var(--colour-interactive-bg);
color: var(--colour-interactive);
outline-offset: 4px;
}

&::scroll-button(*):disabled {
background-color: grey;
color: #4e4e4e;
border-color: #4e4e4e;;
}

&::scroll-button(left) {
content: "⬅" / "Previous slide";
left: 1rem;
}

&::scroll-button(right) {
content: "⮕" / "Next slide";
right: 1rem;
}

.carousel__item {
width: 100%;
height: 100%;
}

.carousel__item img {
min-width: 100%;
height: 100%;
}
}
}
}

I'll give you a brief rundown of the above CSS above:

CSS Carousel: The good bits

Technically we have just added some functional buttons with some new CSS features, what did we want as a bare minimum:

CSS Carousels: Could be better

CSS carousel final thoughts

We didn't go to the lengths that Sara did, because we were only interested in the buttons, we didn't want the pips, which actually change the semantics of everything into tabs, etc. We just wanted a way to advance slides with buttons and enough accessibility info to be present that it makes sense. The buttons are seemingly redundant for a screen reader user, at least using Chrome and VoiceOver, which I know isn't ideal, but I can't test this on Safari. I found the focus being forced on to the body quite jarring, this carousel could be anywhere on a page, a regular keyboard user/voice user or anyone that uses most other keyboard navigation API AT, with the exception of screen reader users could have a hard time getting back to where they were. A screen reader user could at least get back using their Rotor/Elements panel, but even then, why should they have to do that?

I can't speak for screen reader users, I can make observations, though. A regular carousel doesn't advance slides with arrows, if done correctly, as slides that aren't visible are accessibly hidden. That means using arrow keys would move through all of what is visually present, the slides and controls, etc, then exit the carousel. This time it's still operating like our scrollable region, in that each slide will be read, sequentially, without ever having to use the buttons. This could be a good thing or a bad thing, but the question is, how does a screen reader user know that this is a CSS carousel, as opposed to a regular one?

In its current implementation, the focus management alone is enough to put me off using it, as that would be annoying for users. Even if at this stage we had JS at our disposal, we couldn't fix it, as we can't access pseudo elements with JS, so capturing the click event and sending focus to somewhere logical isn't possible. But, at that stage, we may as well just roll our own, I guess.

We're not going to use it, I don't think it's particularly great. It has some good parts, but CSS is very limited, accessibility-wise and I honestly don't think it's the correct tool for the job. It's simple enough to implement, but it feels lacking in places and that's perhaps a limitation of CSS? I'm not fully sure why we have it, how we got here and why it exists, but it's here, albeit experimental, for now and I definitely would not use it. That being said, I'm leaving it in, as it was part of my process. I'd just delete the last code block and move on, though.

We're at that stage now where we can use JS to manipulate the DOM, we don't need to fudge a carousel with CSS and most importantly, we have full control over all accessibility information and the widget's behaviour.

We don't need to change any HTML, we will need to add some CSS and it's likely that some of that CSS will be the same as we have already written in the CSS-only carousel, but don't worry, as we'd just delete the CSS carousel if we were going live with this.

CSS for JS enabled carousel

.has-js {
.carousel__inner-wrap {
position: relative;
display: flex;
flex-direction: column;
}

.carousel__slides {
position: relative;
width: 100%;
overflow-x: hidden;
aspect-ratio: 3/2;
height: auto;
scroll-snap-type: none !important;

.carousel__item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
visibility: hidden;
will-change: transform;
z-index: 1;
scroll-snap-align: none !important;
}

.carousel__item.active {
visibility: visible;
transform: translateX(0);
z-index: 2;
}

.carousel__item.prev-stage {
transform: translateX(-100%);
}

.carousel__item.next-stage {
transform: translateX(100%);
}

@media not (prefers-reduced-motion: reduce) {
.carousel__item.is-sliding {
transition: transform 800ms ease-in-out, visibility 800ms !important;
}
}

@media (prefers-reduced-motion: reduce) {
.carousel__item {
transition: all 0ms !important;
transform: translateX(0);

&.active {
visibility: visible;
}
}
}

.carousel__item img {
min-width: 100%;
height: 100%;
}

&::before,
&::after
{
content: "";
position: absolute;
top: .375rem;
width: 5rem;
left: 50%;
height: 10px;
z-index: 10;
transition: none !important;
translate: -50% 0;
}

&::before {
border: 3px solid var(--colour-interactive-bg);
background-color: var(--colour-interactive-bg);
box-shadow: 0 0 0 2px #fff;
}

&::after {
border: 2px solid var(--colour-interactive-bg);
border-right: none;
transform: scaleX(0);
transform-origin: left;
background-color: var(--colour-interactive);
}

&.animating::after {
animation: fillProgress 5000ms linear forwards;
}

&.paused::after {
animation-play-state: paused;
}
}

&[data-carousel-motion="none"] {
.carousel__slides {
&::after,
&::before
{
display: none;
}
}
}

.carousel__autoplay-toggle,
.carousel__prev-btn,
.carousel__next-btn
{
position: absolute;
display: flex;
justify-content: center;
align-items: center;
line-height: 1;
border: 3px solid var(--colour-interactive-bg);
border-radius: 50%;
padding: .5rem;
height: 2.5rem;
width: 2.5rem;
font-size: 1.4rem;
background-color: var(--colour-interactive);
color: var(--colour-interactive-bg);
cursor: pointer;
z-index: 10;

&:focus-visible {
border-color: var(--colour-interactive);
background-color: var(--colour-interactive-bg);
color: var(--colour-interactive);
outline-color: transparent;
}

&:focus-visible::after {
content: "";
position: absolute;
inset: -6px;
border: 3px solid var(--colour-interactive-bg);
border-radius: 50%;
box-shadow: 0 0 0 2px var(--colour-interactive);
}
}

.carousel__autoplay-toggle {
top: .5rem;
left: .5rem;
font-size: 1rem;

@media (min-width: 36em) {
top: 1rem;
left: 1rem;
}

&[data-play-state="play"]::before {
font-family: "Font Awesome 5 Free";
content: "\F04c" / "";
font-weight: 900;
}

&[data-play-state="pause"]::before {
font-family: "Font Awesome 5 Free";
content: "\F04b" / "";
font-weight: 900;
}
}

.carousel__prev-btn {
bottom: .5rem;
left: .5rem;

@media (min-width: 36em) {
bottom: 1rem;
left: 1rem;
}

&::before {
font-family: "Font Awesome 5 Free";
content: "\F060" / "";
font-weight: 900;
}
}

.carousel__next-btn {
bottom: .5rem;
right: .5rem;

@media (min-width: 36em) {
bottom: 1rem;
right: 1rem;
}

&::before {
font-family: "Font Awesome 5 Free";
content: "\F061" / "";
font-weight: 900;
}
}
}

@keyframes fillProgress {
from {
transform: scaleX(0);
}

to {
transform: scaleX(1);
}
}

@media (forced-colors: active) {
.carousel__slides {
&::before {
background-color: CanvasText !important;
}
}


.carousel__autoplay-toggle,
.carousel__next-btn,
.carousel__prev-btn,
.carousel__cta
{
background-color: ButtonFace !important;
color: ButtonText !important;
border: 4px solid ButtonText !important;

&:focus-visible {
background-color: ButtonText !important;
color: ButtonFace !important;
border-color: ButtonFace !important;
outline: 4px solid Highlight;
outline-offset: 2px;
transform: scale(1.05);
}
}

.carousel__cta {
&:focus-visible {
forced-color-adjust: none;
background-color: ButtonText !important;
outline-width: 3px;
}
}

.carousel__prev-btn::before,
.carousel__next-btn::before,
.carousel__autoplay-toggle::before,
.carousel__autoplay-toggle::after
{
forced-color-adjust: none !important;
background: transparent !important;
background-color: transparent !important;
color: currentColor !important;
transform: scale(1.05);
}

.carousel__prev-btn:focus-visible::before,
.carousel__next-btn:focus-visible::before,
.carousel__autoplay-toggle:focus-visible::before,
.carousel__autoplay-toggle:focus-visible::after
{
color: ButtonFace !important;
}

.carousel__prev-btn:focus-visible::after,
.carousel__next-btn:focus-visible::after
{
border-color: ButtonFace !important;
transform: scale(1.05);
}
}

I'll just summarise some of this:

JS for our carousel

  const carousel = document.querySelector('.carousel');
const carouselInner = carousel.querySelector('.carousel__inner-wrap');
const carouselSlides = carousel.querySelector('.carousel__slides');
const carouselItems = carousel.querySelectorAll('.carousel__item');
const carouselNextBtn = carousel.querySelector('.carousel__next-btn');
const carouselPrevBtn = carousel.querySelector('.carousel__prev-btn');
const carouselAutoplayToggle = carousel.querySelector('.carousel__autoplay-toggle');
const announcements = document.querySelector('.announcements');
let currSlide = 0;

Firstly we're just getting references to many of the elements we'll use throughout.

  const disableAutoplay = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
carousel.querySelector('.carousel__controls').hidden = false;
carousel.removeAttribute('tabindex');
carousel.querySelector('.carousel__slides').setAttribute('role', 'presentation');
carousel.setAttribute('aria-roledescription', 'carousel');
// A small state object, for determing what to do under certain conditions, such as hover, focus within and when the carousel is manually paused
let carouselState = {
isFocusWithin: false,
isMouseWithin: false,
isManuallyPaused: false
};

// We set isManuallyPause to true, on page load if the user prefers reduced motion
if (disableAutoplay) carouselState.isManuallyPaused = true;

// Initialise our slides, set the first one as active
carouselItems.forEach((slide, idx) => {
slide.setAttribute('aria-roledescription', 'slide');
slide.setAttribute('role', 'group');
slide.setAttribute('aria-labelledby', `count${idx + 1}`);
idx === 0 ? slide.classList.add('active') : slide.classList.add('next-stage');
});

// We want our data attributes and buttons to match the correct state for play or pause at all times
const setAutoplayState = (isPlaying) => {
document.documentElement.dataset.carouselMotion = isPlaying ? 'play' : 'none';
carouselAutoplayToggle.dataset.playState = isPlaying ? 'play' : 'pause';
carouselAutoplayToggle.querySelector('.visually-hidden').textContent = isPlaying ? 'Pause' : 'Play';
};

We add an object with some global state values we will need to compare. This is all for determing when the slides should pause or resume. If a slide is only playing for 5 seconds and it contains a link, then by the time you get to that link, the slide has been replaced, that's going to be quite annoying, isn't it? We manage that later, but this is the object we'll refer to, multiple times

If our user prefers reduced motion, we simply set the value to isManuallyPaused to true, so we can grab that, later

We loop through each slide and and add the following:

const navigateToSlide = (idx, direction = 'left') => {
const currentSlide = carouselItems[currSlide];
let nextIndex = (idx + carouselItems.length) % carouselItems.length;
if (nextIndex === currSlide) return;
const nextSlide = carouselItems[nextIndex];

currentSlide.classList.remove('is-sliding');
nextSlide.classList.remove('is-sliding');

if (direction === 'left') {
nextSlide.classList.remove('next-stage', 'active');
nextSlide.classList.add('prev-stage');
} else {
nextSlide.classList.remove('prev-stage', 'active');
nextSlide.classList.add('next-stage');
}

window.getComputedStyle(nextSlide).transform;
window.getComputedStyle(currentSlide).transform;

setTimeout(() => {
currentSlide.classList.add('is-sliding');
nextSlide.classList.add('is-sliding');

if (direction === 'left') {
currentSlide.classList.remove('active', 'prev-stage');
currentSlide.classList.add('next-stage');
} else {
currentSlide.classList.remove('active', 'next-stage');
currentSlide.classList.add('prev-stage');
}

nextSlide.classList.remove('prev-stage', 'next-stage');
nextSlide.classList.add('active');
}, 0);

currSlide = nextIndex;

setTimeout(() => {
currentSlide.classList.remove('is-sliding');
nextSlide.classList.remove('is-sliding');
}, 850);

carouselSlides.classList.remove('animating', 'paused');
void carouselSlides.offsetWidth;
checkAutoplayState();

if (carouselState.isManuallyPaused === true) {
constructAnnouncement(nextSlide);
}
};

Confession time: Firefox nearly broke me, here. I had this working well on other browsers, but due to some browser engine spec and split-second timings, it was broken for Firefox and I got stuck. For my sins, I had to ask an AI agent for some help and ended up with the setTimeouts and a couple of extra classes to mitigate that. It took a while, it took several prompts and eventually, I got there. In essence:

// Toggle classes and states when autoplay starts
const startAutoplay = () => {
setAutoplayState(true);
announcements.textContent = '';

if (carouselSlides.classList.contains('paused')) {
carouselSlides.classList.remove('paused');
carouselSlides.classList.add('animating');
} else {
carouselSlides.classList.add('animating');
}
}

// Toggle classes and states when autoplay stops
const stopAutoplay = () => {
carouselState.isManuallyPaused = true;
setAutoplayState(false);
carouselSlides.classList.remove('animating', 'paused');
}

// Pause autoplay
const pauseAutoplay = () => {
if (document.documentElement.dataset.carouselMotion === 'play') {
carouselSlides.classList.add('paused');
}
}

// Animate the progress bar, for autoplay
const progressAnimation = () => {
carouselSlides.classList.remove('animating', 'paused');
void carouselSlides.offsetWidth;
carouselSlides.classList.add('animating');
}

// Check whether the user has hovered or focused within the carousel
const checkAutoplayState = () => {
const willPause = carouselState.isFocusWithin || carouselState.isMouseWithin;

if (carouselState.isManuallyPaused) return
willPause ? pauseAutoplay() : startAutoplay();
}

A few functions that handle various aspects of autoplay:

const constructAnnouncement = (curr) => {  
announcements.textContent = '';
const knownString = `${curr.querySelector('.carousel__counter').textContent}, ${curr.querySelector('img').alt}. `;
const slideOverlay = curr.querySelector('.carousel__slide-overlay');
let overlayText = '';

if (slideOverlay) {
overlayText = Array.from(slideOverlay.childNodes)
.map(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.tagName.toLowerCase() === 'a') {
return `${node.textContent}, link,`;
} else if (node.tagName.toLowerCase() === 'button') {
return `${node.textContent}, button,`;
}
}
return node.textContent;
}).join('').trim().replace(/\s+/g, ' ');
}

announcements.textContent = `${knownString}${overlayText}`;
}

This one had me for ages, I threw everything at this, trying to get consistent browser experiences and I could not get it to work in the correct way using the simpler approach. Eventually I ended up figuring out what the issue was and it's because Safari handles dynamic changes to live regions differently than other browsers.

In essence, live regions generally need to be empty and then injected with text, before they announce something, right? There is another way, if the live region itself contains accessibly hidden elements and we toggle the hiding method of a child element, then that will announce itself, too. Perfect for a carousel, huh? Well, not always. So, I had my live region on the <ul> element, as every slide has visibility: hidden, unless it is the active slide. So we are changing the contents of a live region by exposing new stuff. I do know we have an intermediate state where two slides are technically exposed, but believe me when I say I threw everything at this, I tried aria-hidden and inert, which I held off on for half a second, I even bypassed the animation and used hidden and display: none;, I tried them all during my troubleshooting and it simply would not work in Safari.

There is a W3C APG accessible carousel example, go ahead and test this with Safari and VoiceOver, if you can. Silence on every press of the Previous and Next buttons, right? Not good, it works on Chrome, sort of, but I found I was getting double announcements, I will concede that I do have a pending browser update, I don't update my browser or OS mid task. I know that Chrome and VoiceOver isn't the recommended pairing, but a small number of people actually do use that pairing and I still like to check, because there's no harm in me modifying my own code to work across lesser used pairings.

So, what was the issue, I hear you ask? We don't want to have the carousel announce anything on autoplay, because that's quite rude, it would possibly interupt our user, should they pause long enough for the live region to fire (I did use "polite"), that would be quite awful. We could have looked into only autoplaying when the carousel is on screen and paused when above/below the fold, but that's not a great approach, as the user could have the carousel on screen and it would not be the only thing on screen on certain devices or viewports. If they pause, it starts yapping, not good.

Our carousel only announces anything when a user manually controls it, using either of the Previous or Next buttons. The moment they are interacted with, we stop autoplay and use logic to move the slides in the correct direction. Before I got this working, I simply toggled the value of aria-live from "off" to "polite" when the user grabbed the wheel, so to speak. That would have been perfect had it worked universally, but Apple be Appling and they do things a little different. I genuinely don't know who's doing it correct, I'm not hating on Apple, I'm using my own Mac, here, I'm just saying they're often different.

What I expected to happen on both my effort and the W3C's is toggle the live region to "polite", then, announcements could be made via updates to the accessibility tree as we dynamically hide and show content. If when the page loads, that live region is set to "off", it seemingly stays off, forever, in Safari and Safari alone. The rabbit hole I went down resulted in me partially understanding what the issue could be. At first it appeared that as both the live region and new slide toggled virtually simultaneously, that Safari ignored the step, so I introduced delays, by adding aria-hidden to a slide and then removing it 500ms into the transition phase. I even tried a longer delay, to no avail, but I wouldn't have gone longer, as feedback needs to be pretty quick on a button, right? I just could not get it to reliably work and I'm not going to pop a guide on here that I know doesn't work on a popular browser with the correct screen reader for that pairing

I had to create my own live region, which was already primed with the "polite" value and then replicate what would be announced had the the previous attempt worked. At a minimum, we have a "Slide [n] of 5", [image alt text]", but sometimes, we have more, well, just on one slide here, but I could have added anything to the .slide__overlay container, which is just there to add additional items and controls.

Technically, I didn't need to do anything special, here, I could just have grabbed the counter, the image alt and the textContent of the .slide__overlay container and concatenated that string, before injecting our live region. That is in essence how these mostly work, right? Maybe I overengineered a tiny bit, here, but ordinarily, I would not put something large in a live region, it may just provide instant feedback from a Search such as "5 results" or it may announce an action has resulted in success or error, etc, we wouldn't put that live region on an entire section of content, because it will just read as much as the buffer is set to, if there is a buffer, right? Yuk.

Live regions only announce text, so we would get "Slide 1 of 5, [img alt], About London". About London is a link, but that's not communicated, because sematics are ignored, by design. Live regions are supposed to be small snippets of time-sensitive info, but a carousel could have text heavy slides, huge alt text, controls for changing the colour of a product, CTA links or other controls. We're encouraging screen reader users to remain on the buttons, by announcing the entire slide content, we're trying to provide a comparable experience that means they don't need to go off looking for content within. My link has a pretty decent label/AccName "About London" which may be enough of a clue, but then it may not. What if it were just "London", it would in all likelihood just be interpreted as the slide name and not immediately understood to be a link, I can't say for sure, but would the presence of a link be understood from "About London" or even just "London" at the end of the announcement? Maybe by some, maybe not others, I guess.

So, what I did was search inside the string for the presence of links and buttons, if they are present, I add ", link," or ', button," directly after that substring, then glue everything back together, do some basic stripping of extra spaces and pass it into the live region. We then end up with "Slide 1 of 5, [img alt], About London, link". Which may be useful, but we could only know that from user testing. I may well be over-engineering, but I'm not saying go use this, I'm saying ask your screen reader users if they find it helpful or not, If they do, keep it, if not, toss it to the side and carry on with your day.

We could have added "link" to the end of the link's label and hid it, visually, but then focussing on that link would be "Link, About London, link", which is unneccesary repetition and makes the experience worse for screen reader users and and at least on Apple devices, increases the number of words to be spoken by voice input users to activate the control. My approach seems way better than that, but, maybe not doing what I did is even better? There is a contact email above the footer, if you have any thoughts, please do get in touch.

// Listen for clicks on the Play/pause button, flip the isManuallyPause boolean, and either paly or stop
carouselAutoplayToggle.addEventListener('click', (btn) => {
carouselState.isManuallyPaused = !carouselState.isManuallyPaused;
btn.target.dataset.playState === 'play' ? stopAutoplay() : startAutoplay();
});

// Move to next slide
carouselNextBtn.addEventListener('click', () => {
stopAutoplay();
navigateToSlide(currSlide + 1);
});

// Move to previous slide
carouselPrevBtn.addEventListener('click', () => {
stopAutoplay();
navigateToSlide(currSlide - 1, 'right');
});

// Using the GPU, when the progress bar hits the end, advance to next slide
carouselSlides.addEventListener('animationend', (evt) => {
if (evt.animationName === 'fillProgress') {
navigateToSlide(currSlide + 1);
progressAnimation();
}
})

// Detect mouse presence within
carouselInner.addEventListener('mouseenter', (evt) => {
carouselState.isMouseWithin = true;
checkAutoplayState()
})

// detect the mouse leaving
carouselInner.addEventListener('mouseleave', (evt) => {
carouselState.isMouseWithin = false;
checkAutoplayState()
})

// Detect focus within on all but the Play/Pause buttons
carouselInner.addEventListener('focusin', (evt) => {
if (evt.target === carouselAutoplayToggle) {
carouselState.isFocusWithin = false;
} else {
carouselState.isFocusWithin = true;
}
checkAutoplayState()
})

// Detect focus leaving
carouselInner.addEventListener('focusout', (evt) => {
const newActiveEl = evt.relatedTarget;

if (!carouselInner.contains(newActiveEl) || newActiveEl === carouselAutoplayToggle) {
carouselState.isFocusWithin = false;
}
checkAutoplayState()
})

// Immediately disable autoplay if user makes a system change to reduced motion
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (evt) => {
if (setAutoplayState) {
stopAutoplay();
}
})

// If reduced motion was detected on load, don't autoplay, else do
disableAutoplay ? setAutoplayState(false) : startAutoplay();

I've added comments to each event listener, we'll just leave it there, as I'm sick of myself, at this stage and I can imagine you're sick of reading what has turned into War and Peace.

What we have

What we could improve

Screen shots

CSS only carousel, on landscape screen

CSS only carousel, on landscape screen, showing the entire carousel fits in the viewport

No JS and no CSS carosel, using overflow

The carousel when there is no JS and the CSS carosel buttons are not supported. The carousel is simply a scrollable region

Carousel in High Contrast Mode

Carousel in High contrast Mode, showing highly visible controls, one control has keyboard focus to show the focus rings use multiple rings to prevent clashes with the underlying image

Carousel with focused control

The carousel showing one control is focused, The colours for the controls and their focus indicators are brighter than on high contrast mode, as we are using custom colours as opposed to permitted colours

Wrapping up

I think we covered everything with this. I'm sure I covered all of the bases for folks with a variety of accessibility requirements. That being said, don't ever just copy my stuff and call it good, it's only ever a thought process and prototype, it would need more testing and most importantly testing with paid disabled users.

Over the coming weeks, I'll pick up Part 2 of this. We'll probably not include all of the code, I'll just neuter the bits we don't need for the pips/dots approach and we'll just modify the original code, which shouldn't be too much effort, I think we have most of it and may require just a few extra functions and some additional CSS. I say that now, but everything seems straightforward when it's just an idea.

The CodePen

Image attribution

All images were taken from Unsplashed and are free to use under their free license:

David Monaghan, Tower Bridge, London

Timo Volz, Shibuya Intersection in Tokyo

Jermain Ee, New York, Central Park

Chris Karidis, Eiffel Tower, Paris France

VactionTravelInsider dot com, Marina Bay Sands Resort, Singapore

Share on:

TwitterLinkedIn

Site preferences

Please feel free to display our site, your way by finding the preferences that work best for you. We do not track any data or preferences at all, should you select any options in the groups below, we store a small non-identifiable token to your browser's Local Storage, this is required for your preferencesto persist across pages accordion be present on repeat visits. You can remove those tokens if you wish, by simply selecting Unset, from each preference group.

Theming

Theme
Code block theme

Code theme help

Code block themes can be changed independent of the site theme.

  • Default: (Unset) Code blocks will have the same theme as the site theme.
  • Light 1: will be default for users viewing the light theme, this maintains the minimum 7:1 (WCAG Level AAA) contrast ratio we have used throughout the site, it can be quite difficult to identify the differences in colour between various syntax types, due to the similarities in colour at that contrast ratio
  • Light 2: drops the contrast for syntax highlighting down to WCAG Level AA standards (greater than 4.5:1)
  • Dark: Syntax highlighting has a minimum contrast of 7:1 and due to the dark background differences in colour may appear much more perceivable

Motion

Motion & animation

Motion & animation help

  • Default (Unset): Obeys device settings, if present. If no preference is set, there are subtle animations on this site which will be shown. If you have opted for reduce motion, smooth scrolling as well as expanding and collapsing animations will no longer be present, fading transtitions and micro animations will still be still present.
  • None: All animations and transitions are completely removed, including fade transitions.

Links

Underline all links

Underline all links help

  • Default (Unset): Most links are underlined, with a few exceptions such as: the top level links in the main navigation (on large screens), cards, tags and icon links.
  • Yes: Will add underlines to the exceptions outlined above, resulting in every link being underlined

Text and paragraphs

Font size (main content)

Font size help

This setting does not apply to the site's header or footer regions

  • Default (Unset): Font sizes are set to site defaults
  • Selecting Large or Largest will increase the font size of the main content, the size of the increase depends on various factors such as your display size and/or zoom level. The easiest way to determine which option suits you best would be to view this text after clicking either size's button
Letter spacing

Letter spacing help

  • Default (Unset): Default letter spacing applies
  • Increased: Multiplies the font size by 0.12 and adds the sum as spacing between each character
Line height

Line height help

  • Default (Unset): all text has a minimum line height of 1.5 times the size of the text
  • Increased: all text has a line height of twice the size of the text
Line width

Line width help

  • Default (Unset): all text has a maximum line width of 80 REM units (this averages around 110 characters per line)
  • Decreased: all text has a maximum line width of 55 CH units (this averages around 80 characters per line)
Paragraph spacing

Paragraph spacing help

  • Default (Unset): The space between paragraphs is equivalent to 1.5 times the height of the paragraph's text
  • Increased: The space between paragraphs is equivalent to 2.25 times the height of the paragraph's text
Word spacing preference

Word spacing help

  • Default (Unset): No modifications to word spacing are present
  • Increased: Spaces between words are equivalent to 0.16 times the font size