🛠️ Carousel: Screen reader accessibility

Hey ,

I'm thrilled to help you learn JavaScript. Unfortunately, you've landed on a page where you cannot access with your current purchase.

Please upgrade (use this link) access this content.

I'm super eager to help you learn more!

🛠️ Carousel: Screen reader accessibility

A carousel is a prominent section of a page. It holds content important enough to warrant a landmark role. Of the 8 landmark roles, the only one a carousel can use is region.

This is why we marked up the carousel with a <section> element. (The <section> element has an implicit region role).

<section class="carousel">...</section>

Each landmark should have an accessible name. In this case, let’s use “Carousel” as the accessible name.

We’ll set the accessible name with aria-label since the words “Carousel” cannot be found on the page.

<section class="carousel" aria-label="Carousel">...</section>

Users can change carousel slides by using the ← and → keys. This method works perfectly for Voiceover users.

Unfortunately, this method won’t work for NVDA users. It doesn’t work for NVDA because NVDA uses → to speak the next character and ← to speak the previous character.

We need to create another method for NVDA users.

You might beat yourself up at this point. You might think you’ve wasted time and energy, and you have to throw away the ← and → functionality.

Don’t beat yourself up. Our work is not wasted. Voiceover users and non-screen reader users can still use ← and → keys. Think of it as an enhancement for them.

We simply have to create a fallback for NVDA users.

Allowing NVDA users to switch slides

We know we cannot use arrow keys for NVDA users. But we also cannot use control, alt, or shift modifiers with arrow keys. They’re all used for other commands.

Key Command
Left Say previous character
Right Say next character
Control + Left Say previous word
Control + Right Say next word
Alt + Left Previous page (browser shortcut)
Alt + Right Next page (browser shortcut)
Shift + Left Selects previous character (system shortcut)
Shift + Right Select next character (system shortcut)

We need a simple way a user can understand and use the interface. The simplest way is let them use the previous and next buttons.

Using previous and next buttons

First, we need to remove the tabindex attribute on the previous and next button elements so users can access them.

<!-- Removed tabindex="-1" from both buttons -->
<section class="carousel" aria-label="Carousel">
  <button class="carousel__button previous-button" hidden> ... </button>
  <!-- ... -->
  <button class="carousel__button next-button"> ... </button>
</section>

We also need to give each button an accessible name. We’ll use “Previous slide” and “Next slide”.

<section class="carousel" aria-label="Carousel">
  <button class="carousel__button previous-button" aria-label="Previous slide" hidden> ... </button>
  <!-- ... -->
  <button class="carousel__button next-button" aria-label="Next slide" > ... </button>
</section>

Keeping focus on buttons

When a user hits the next button, they expect their focus to remain on the next button. We shouldn’t move their focus into the slide.

Right now, focus moves into the slide because we used the focus method in switchSlide. We need to remove it.

const switchSlide = (currentSlideIndex, targetSlideIndex) => {
  // ...
  // Remove these
  targetLink.focus({ preventScroll: true })

  // Fallback for preventScroll
  setTimeout(() => {
    contents.parentElement.scrollLeft = 0
  }, 0)
  // ...
}

This is much better!

Shifting focus for arrow key users

We still need to shift focus to the correct slide if a people who use ← and → to switch slides.

First, let’s remove the code we have for the arrow key event listeners.

contents.addEventListener('keydown', event => {
  // ...
  // Remove these
  if (key === 'ArrowLeft') previousButton.click()
  if (key === 'ArrowRight') nextButton.click()
})

We only want to switch slides if the user presses the left or right arrow keys. We’ll use an early return statement to make the function simpler.

contents.addEventListener('keydown', event => {
  const { key } = event
  if (key !== 'ArrowLeft' && key !== 'ArrowRight') return
})

When a user presses →, we want to switch to the next slide. We can get the next slide once we find the current slide.

contents.addEventListener('keydown', event => {
  // ...
  const currentSlideIndex = getCurrentSlideIndex()
  let targetSlideIndex

  if (key === 'ArrowRight') targetSlideIndex = currentSlideIndex + 1

  switchSlides(currentSlideIndex, targetSlideIndex)
})

The maximum value for targetSlideIndex is the index of the last slide. We need to make sure targetSlideIndex doesn’t exceed this value.

contents.addEventListener('keydown', event => {
  // ...
  if (key === 'ArrowRight') targetSlideIndex = currentSlideIndex + 1
  if (targetSlideIndex > slides.length -1) targetSlideIndex = slides.length - 1

  switchSlides(currentSlideIndex, targetSlideIndex)
})

When a user presses ←, we want to switch to the previous slide. We know the previous slide is currentSlideIndex - 1.

contents.addEventListener('keydown', event => {
  // ...
  if (key === 'ArrowLeft') targetSlideIndex = currentSlideIndex - 1
  switchSlides(currentSlideIndex, targetSlideIndex)
})

The minimum value for targetSlideIndex is the index of the first slide (which is 0). We need to make sure targetSlideIndex doesn’t exceed 0.

contents.addEventListener('keydown', event => {
  // ...
  if (key === 'ArrowLeft') targetSlideIndex = currentSlideIndex - 1
  if (targetSlideIndex < 0) targetSlideIndex = 0
  switchSlides(currentSlideIndex, targetSlideIndex)
})

After switching slides, we need to use the focus method to shift focus.

contents.addEventListener('keydown', event => {
  // ...
  // Focus on the correct slide
  const targetLink = slides[targetSlideIndex].querySelector('a')
  targetLink.focus({ preventScroll: true })

  // Fallback for preventScroll
  setTimeout(() => {
    contents.parentElement.scrollLeft = 0
  }, 0)
})
Users can still arrows to change slides. Focus still goes to the correct slide.

Announcing slide changes

Sighted users can see a change happening when they click (or hit Enter on) a button. They can see the carousel move. But blind users can’t. We need to inform them of the change with a live region.

Let’s start by creating a live region within the carousel.

We’ll use an alert role because the information is important and time-sensitive. Users need feedback that something has changed.

<section class="carousel" aria-label="Carousel">
  <div role="alert"></div>
  <!-- ... -->
</section>

This live region is only for screen readers. We’ll add an sr-only class to hide it from sighted users.

<section class="carousel" aria-label="Carousel">
  <div role="alert" class="sr-only"></div>
  <!-- ... -->
</section>
.sr-only {
  position: absolute;
  width: 1px;
  height: auto;
  margin: 0;
  padding: 0;
  border: 0;
  clip: rect(0 0 0 0);
  overflow: hidden;
  white-space: nowrap;
}

For this carousel, let’s announce the selected slide when the user presses the previous or next button.

const liveregion = carousel.querySelector('[role="alert"]')

const switchSlide = (currentSlideIndex, targetSlideIndex) => {

  // Announce selected slide for screen reader users
  liveregion.textContent = `Slide ${targetSlideIndex + 1} of ${slides.length} selected`
}

Voiceover users would hear the slide live region perfectly:

Unfortunately, NVDA says “alert” if we use the alert role.

This is more of a status change than an “alert”. The “alert” may throw users off. So we’ll switch to a status role with aria-live set to assertive instead.

<div role="status" aria-live="assertive" class="sr-only"></div>
const liveregion = carousel.querySelector('[role="status"]')

Removing visual instructions

If a sighted NVDA user uses our carousel, they may try to use ← and → keys to switch slides. They’ll do this because we added instructions for them to do so.

Instructions to use ← and → keys.

But we know NVDA users cannot use ← and → keys to switch slides. We need to remove this set of instructions for NVDA users.

Unfortunately, there’s no way to detect whether a user is using a screen reader, much less which screen reader they’re using. So we need to remove this set of instructions completely.

<!-- Remove this -->
<div class="carousel__overlay">
  Use &larr; and &rarr; to switch slides.
</div>
/* Remove this */
.carousel__overlay { /* ... */ }
Removed overlay instructions.

Normal users and Voiceover users won’t know about ← and → keys. They’ll have to discover the ← and → shortcuts by themselves.

This is okay because the ← and → shortcuts are enhancements. It’s not critical for users to know about them.

Try navigating through a carousel with a screen reader’s “Next Item” command. The dots and slides will go out of sync.

This is normal.

It happens because screen readers don’t send browsers any events when they use “Next Item”. As a result, browsers can’t send us events too.

We can solve this sync issue by preventing screen readers from “hidden” slides. We can do by:

  1. Setting aria-hidden="true" to other slides
  2. Setting visibility: hidden to other slides

Setting aria-hidden to other slides

The first method is to set aria-hidden="true" true to other slides. When slides change, we switch the aria-hidden="true" property (like roving tabindex).

This works on NVDA.

But there’s a bug on Safari. It shows the last slide.

Since aria-hidden doesn’t solve the problem completely, we’ll use the visibility: hidden method instead.

Setting visibility hidden to other slides

We can hide slides by adding visibility: hidden to them. We’ll only show the selected slide by changing visibility back to visible.

.carousel__slide {
  /* ... */
  visibility: hidden;
}

.carousel__slide.is-selected {
  /* ... */
  visibility: visible;
}

This works on NVDA and Voiceover.

However, it breaks the carousel’s transition animation slightly. It causes the outgoing slide to disappear before the animation is complete.

We can fix this by delaying the visibility transition.

.carousel__slide {
  /* ... */
  visibility: hidden;
  transition: visibility 0s 0.3s linear;
}

.carousel__slide.is-selected {
  /* ... */
  visibility: visible;
  transition: visibility 0s 0s linear;
}

Removing list semantics

If you pay attention to NVDA’s message, you’ll notice NVDA says: “List with one item”. But when we switch slides, it says “Slide X of 3”.

This is confusing. What does the carousel contain? List items or slides?

The best way to resolve this problem is to remove list semantics by setting role of the list to presentation or none. NVDA won’t say “List with one item” once we do this.

<ul role="presentation" class="carousel__contents"> ... </ul>

Hiding dots from screen readers

Screen reader users can move through the slides with the Left and Right buttons. They don’t need to access dots at all.

We can hide dots from screen readers by setting aria-hidden to true.

function createDots (slides) {
  const dotsContainer = document.createElement('div')
  dotsContainer.classList.add('carousel__dots')
  dotsContainer.setAttribute('aria-hidden', true)
  // ...
}

There are two things in each slide:

  1. Links
  2. Images

Each link should have an accessible name. This tells users where they will go to.

Normally, the accessible name is the link’s text content. We don’t have text content in this case so create the accessible name with the title attribute.

<li class="carousel__slide is-selected">
  <a href="..." title="One"> ... </a>
</li>
<li class="carousel__slide">
  <a href="..." tabindex="-1" title="Universe"> ... </a>
</li>
<li class="carousel__slide">
  <a href="..." tabindex="-1" title="Learn JavaScript"> ... </a>
</li>

Images

There are two kinds of images:

  1. Images that are meant to convey a message
  2. Decorative images

If you have an image that conveys a message, you need to write an alt text for the image.

If you have decorative images (no meaning to convey), you need to have an empty alt text.

For this carousel, the images decorative images. That’s why I chose to leave the alt text empty.

That’s it!