Thanks for being a part of WWDC25!

How did we do? We’d love to know your thoughts on this year’s conference. Take the survey here

VoiceOver navigation in carousels

Hi all,

I’ve got a usability question about accessibility navigation. My app has a lot of carousels (horizontally scrolling lists of content with far more elements than can fit on the screen). Often, these are just images, but sometimes, they’re cards with multiple subelements. In our previous implementation, each card was a single accessibility element, and we exposed the subelements as accessibility custom actions. Despite this, users frequently mentioned navigating with VoiceOver as a pain point. It takes a long time to navigate through and navigate past these carousels. To solve this, I converted my carousels into a single adjustable element, so users can navigate through it with one swipe, and they can still access the elements by adjusting the values up and down. I got this advice from this 2018 WWDC talk.

Is this still the recommended advice? Or is there a new, preferred way to do this?

Additionally, I had to get a little creative with the second carousel, the one with multiple subelements. Some of these were interactive (imagine a card with a description, an upvote button, and a downvote button). Adjustable elements override the accessibility custom actions VoiceOver gesture, so I can’t expose the individual buttons as actions. Instead, I made each subelement in each card in the carousel one of the adjustable values. Swiping up would go from description 1 to upvote button 1 to downvote button 1 to description 2, etc. Double tapping with VoiceOver would perform whatever action the carousel is currently on. So if I adjust the value to the element at index 2 (say, downvote 1), double tapping would trigger the downvote button’s action.

Does this make sense? Is there a better way to do this? This seemed to be the best compromise between screenreader navigation speed, exposing all actions, and the existing UI.

Building upon this question...

I'd be curious to hear if there are concerns, or possible mitigations, related to presenting groups of content, like carousels, as adjustables. Intuitively, using an adjustable for a leaf UI element that exposes a singular value makes total sense to me, but relying on this approach to essentially control a scroll position in a nested container or surface content for navigation within a collection seems like more of a reach.

To be specific, my concern would be around discoverability of the content within the carousel, particularly among novice users of VoiceOver, who may only be familiar with the notion of default navigation, and may have not encountered situations where content -- potentially important content with associated actions, as described above -- is presented with this alternative navigation approach. In such a scenario, are we confident that users would be able to discover how to navigate reliably within the carousel's content, or is there another recommendation that would help users to skip past long-scrolling carousels for efficiency's sake while maintaining a traditional approach to navigating linearly through that UI?

Appreciate any input on this!

The advice about grouping cards all as one element works well if the entire card is one semantic group like a photo, or a simple text view, or a label with a single action button. This advice starts to break down a little—to your realization—when the cards themselves have a lot of elements.

Iterating through the elements via custom actions works but it's not the best experience if the cards are large or if the elements within the card contain actions like you mention.

My advice is to stop grouping the cards as one element each. VoiceOver users will expect to be able to swipe through all of the elements individually using standard navigation gestures (instead of custom actions). Save the custom actions gesture for actual actions, like upvote and downvote if the element has such controls.

Overriding the behavior for accessibilityScroll(_:) is going to be the key to achieving this. This method gets triggered when an accessibility scroll—or three-finger swipe—is performed. You'll want to move to the next card in the carousel in this case.

To summarize, accessibility scroll would be used to move from card to card and standard navigation gestures like swiping left or right would be used to iterate through the elements inside the card. Navigating beyond the last element in the card should move VoiceOver's focus to the first element in the following card (if there is one).

Thanks for your previous input on carousel accessibility. We have a few follow-up questions:

  1. Adjustable Trait Clarification: Thanks for the clarification regarding accessibilityCustomActions for navigation; I believe we're aligned on that front. Is there guidance on using .adjustable for a carousel depending on whether cards have single vs. multiple actionable elements? Or is the recommendation to avoid .adjustable for carousels altogether?
  2. Skipping Entire Carousel:
    • Our research has found, particularly for carousels with many items, it can be inefficient for users to navigate linearly through the entire carousel's contents in order to reach UI elements that appear after it. If content inside the carousel should be focusable with standard navigation gestures, is there a recommended method for VoiceOver users to skip an entire carousel and navigate to the next element?
    • VoiceOver's documentation refers to a gesture that allows users to "Move out of a group of items", which seems like it might be relevant here. Would this concept of group navigation apply in the case of carousels? If so, is there developer documentation covering how we can support this?
  3. VoiceOver Focus on Three-Finger Swipe (Pagination): We tried overriding accessibilityScroll(_:) as it seems scrolling the carousel does not cause VoiceOver focus to move, even if the previously focused element goes off screen. Would we be expected to manage this manually, e.g. with UIAccessibilityPostNotification, after the carousel scrolls to the next or previous element? For context, here's a simplified version of the prototype I wrote after you suggested using accessibilityScroll:
private func currentlyFocusedElementIndex() -> Int? {
  guard let focusedElement
      = UIAccessibility.focusedElement(using: .notificationVoiceOver)
        as? AnyObject
    else {
      return nil
    }
    // Return the index of the currently focused carousel element. If VoiceOver 
    // focus is not within the carousel, return nil.
    //
    // In a real app, this would be more complicated, because we have multiple
    // elements per card, and we want to jump between cards, not individual
    // elements. But the structure would be the same.
    return self.cards.index { $0 === focusedElement }
  }
  
// accessibilityScroll(_:) calls this method
private func handleAccessibilityScroll(_ direction: UIAccessibilityScrollDirection) -> Bool {
    guard let currentIndex = currentlyFocusedElementIndex() else {
      return false
    }
    if direction == .right {
      guard currentIndex.cardIndex > 0 else {
        return false
      }
      self.scrollToIndex(currentIndex - 1, animated: true)
      return true
    } else if direction == .left {
      guard currentIndex.cardIndex < self.cards.count - 1 else {
        return false
      }
      self.scrollToIndex(currentIndex + 1, animated: true)
      return true
    }
    return false
  }

  private func scrollToIndex(_ index: CarouselItemIndex, animated: Bool) {
    let newFrame = self.cards[index.cardIndex].view.frame
    self.carouselView.scrollRectToVisible(newFrame, animated: animated)
    UIAccessibility.post(notification: .layoutChanged,
                             argument: self.cards[index])
  }

Hello! Happy to answer a few of these, let me know if this helps!

  1. I think adjustable works well for carousels in some cases, and not others. It's kind of up to you as a developer to decide if this makes sense for your app. Personally, where I've found combining a carousel into one adjustable element to work well is in the following 2 cases. The first, is when you are implementing some sort of picker, that is a horizontal list. The "focused" item of the carousel in this case is often the selected item. An example of this would be the Animoji picker in Messages. The other is when the carousel is infinitely scrolling, or looping. In this case, grouping into one element is useful so that VO users don't get stuck swiping over the same elements in a loop without being able to get to the content underneath it. Grouping carousels tends to work less well when you have sub-elements in the items in the carousel. If you have a looping/infinite carousel that also has sub-elements, I think you'll just need to weigh these tradeoffs and get feedback from your users about which works best. Note that you can make one adjustable element, and also give that element custom actions. So, if you do decide to make an adjustable element, you can change the custom actions of that element to represent the sub-elements of the selected card if you need to.

  • a) VoiceOver rotors are how we recommend users jump through sections of your app. The headings rotor and containers rotors are likely best for this. To mark something as a heading, use the heading trait. VoiceOver can then jump between all UI elements marked as headings. To mark something as a container, you can set the accessibilityContainer to be a container type of semanticGroup, on the parent view that contains the views or elements you want to group together. In SwiftUI, you'd just .accessibilityElement(children: .contain). VoiceOver will move focus to the first leaf node item in the container when navigating via the containers rotor.

  • b) This is referring to grouped navigation style, which is a VO preference. In VoiceOver settings, users can change between a navigation style of flat or grouped. When in grouped mode, items that are marked as containers (like I explained in 2a), will be focusable when swiped to, and a 2 finger swipe left or right will drill in and out of these containers. Most users continue to use flag navigation, so I would not rely on this to solve navigation pain points in your app. It's certainly a good idea to test your app in both modes though!

  1. VoiceOver should automatically refocus on a new element if the previously focused element is no longer visible or on screen. Posting a layout change as you are doing is reasonable but shouldn't be necessary. If you can file a bug through feedback assistant with a sample project, that would be helpful!
VoiceOver navigation in carousels
 
 
Q