Skip to content

Comments

chore: Carousel or Image Rotator RFC#9084

Open
nwidynski wants to merge 3 commits intoadobe:mainfrom
nwidynski:2025-carousel-rfc
Open

chore: Carousel or Image Rotator RFC#9084
nwidynski wants to merge 3 commits intoadobe:mainfrom
nwidynski:2025-carousel-rfc

Conversation

@nwidynski
Copy link
Contributor

View Rendered RFC

Related discussions:

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for all your hard work on the RFC! The team was able to take a look at it briefly today (and will hopefully carve out some more time to read through it again and discuss more deeply) and the preliminary feedback/gut feelings is as follows:

  • Patterns like Tabs and GridList/ListBox are featured heavily, perhaps we can extend those hooks instead of making a separate carousel package?
    • However, from my reading of this RFC, there are considerations to be made when binding the various extra controls to the carousel's elements as well as considerations for announcements for when elements are scrolled into/out of view for presentational carousels
  • Have you been able to or tried to build this Carousel yourself already with the existing hooks/components (Tabs/GridList/CardView/etc)? I think from previous conversations in related issue point to yes (especially since you've highlighted difficulties with getting infinite looping to work), but its a bit hard to tell across the disparate conversations haha. I think it would be helpful if you could add some of the pain points/related discussions as supplements for why some of the additional hooks/delegates/etc should be created if possible, if only to make it less theoretical sounding if that makes sense.
  • If we were to only support "Tabbed" for now, what could be dropped for a first pass? Adding this incrementally would be ideal and easier to digest for the team


## Features

A carousel can be built using `<div role="tab">` and `<div role="tabpanel">` HTML elements, but this only supports one perceivable element at a time. Carousel helps you build accessible multi-view rotation components that can be styled as needed.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

something that isn't quite clear to me is whether or not a "presentational" carousel is truly a carousel or just a horizontally scrolling cardview/listbox/gridlist. I suppose this is where the screenreader announcements and support for the various carousel controls may play a role in differentiating them, but I'll need to check the aria pattern.

Copy link
Contributor Author

@nwidynski nwidynski Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully answered by the other comments, but TLDR; A presentational carousel is basically just a useSelectableCollection which has given up control over its scroll offset to ScrollManager.

ARIA semantics of the controlled collection are largely left untouched, depending on how we decide to model the relationship between picker dot and controlled item/scroll target. As you already suspected, the essence of a carousel lies in the ARIA semantics of controls and their announcement of scroll/visibility state.

Comment on lines +122 to +124
- **Single-view transitions** — only one slide is visually emphasized at a time. This may be implemented with fading, crossfading, direct replacement, or even a scroll-based presentation where multiple slides are force-mounted, but only the currently selected slide is highlighted.

- **Multi-view transitions** — multiple slides are perceivable at once, typically arranged in a scroll container where adjacent slides remain partially or fully visible.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to as mentioned above, these experiences feel like behaviors covered in part by existing components (tabs/listbox/gridlist/etc) so I wonder if they can be extensions of those components somehow.

A RAC level Carousel component seems reasonable, but maybe we could extend the existing hooks for those patterns rather than create new carousel hooks?

Copy link
Contributor Author

@nwidynski nwidynski Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to be sure, are you referencing carousel hooks or rotator hooks? The following TLDR; will hopefully make it easier to reason about their distinct scopes:

  • @react-aria/rotator -> A more advanced ScrollView, with built-in scrollIntoView & a playback delegate to answer "which scroll target comes next when i press the next button?"

  • @react-aria/carousel -> W3C spec, event wiring for playback controls & a live announcer for the active target.

Now I don’t think we would want to put an entirely different W3C spec inside of another package, so I figure you are referring to the rotation part? In any scenario,the existing listbox and gridlist hooks continue to be run next to the ones we add here, and we do rely on them. Hopefully I can make this clearer by providing the hook representation for a virtualized presentational carousel:

<useCarousel & useListState & useScrollManagerState>              // => <Carousel />
    <useButton slot="previous" />
    <useButton slot="next" />

    <useSlidePickerList>                                          // => <SlidePickerList /> 
        <useSlidePicker id="1" />
        <useSlidePicker id="2" />
        <useSlidePicker id="3" />
    </>

    <useVirtualizerState>                                         // => <Virtualizer />
        <useSelectableCollection & useListState & useScrollState> // => <ListBox />
            <useSelectableItem & useScrollTarget id="1" />
            <useSelectableItem & useScrollTarget id="2" />
            <useSelectableItem & useScrollTarget id="3" />
        </>
    </>
</>

The only reason for why useTab can not also be re-used here for pickers is because it's not really built for virtualization of panels, enforces a specific JSX placement and requires a tab list state instead of a list state.

I could only see this integrate even deeper if the @react-aria/selection package were to internalize the @react-aria/rotator package, replacing the functionality of scrollIntoView. I purposefully decided not to do that, because I felt like the selection package is already complicated enough as is.

For further clarification, please also reference these sections in the RFC (copy & paste the links, click doesnt work for highlight)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gotcha, I guess would the Carousel provide some/all of the carousel item props via context or would there be a useCarouselItem hook here? My initial thought would be that we could consider adding carousel specific behaviors/aria attributes into the listbox/gridview hooks if those behaviors/aria attributes were "lightweight" per say, but that doesn't seem to be the case

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The W3C Spec for the Carousel Pattern has surprisingly little requirements for carousel items, aka. Slide elements. In fact, the only requirement is for them to carry a valid ARIA role and unique accessible name. Since we can guarantuee listbox/gridview items to already meet this criteria, the only case where useSlide would likely actually be leveraged is in a tabbed carousel w/ SlideShow.

Otherwise, there is useScrollTarget which is coming from the @react-aria/rotator package and is used to report scroll area data into its ScrollDelegate. Similarly to useVirtualizerItem, this hook is invoked for each item via a collection renderer - for which #8523 (comment) is the blocker.

In case there are certain accessibility properties i missed out on during exploration, they could probably be internalized within the listbox/gridview hooks like you mentioned 👍


### Scroll containers

At a high-level, `@react-stately/rotator` will implement a lightweight scroll and snapping observer based on the CSS Overflow Module Level 3 and CSS Scroll Snap Module Level 1 specification. Since scroll destinations are tightly coupled to layout information, the package's architecture will feel similar to the existing `Layout` and `Virtualizer` implementation. More specifically, the RFC proposes the addition of the following new classes:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So these below are all mainly to support snap scrolling for a virtualized experience correct?

Copy link
Contributor Author

@nwidynski nwidynski Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Primarily yes, although they also take on a couple other major responsibilities:

  • determining when rotation control state is updated (eager or lazy)
  • calculating the active scroll target(s) based on layout and collected ScrollArea data
  • determining the playback order of targets when controls are engaged, aka. "which item do we scroll to when hitting next/previous"
  • allowing for easy extension/customization, similar to different layouts

The classes are designed to be very computationally light, since they do not stay in sync with layout, but rather just call the layout for the current state when rotation is scheduled or observed. Extension capabilities are especially useful for a component to customize its own rotation behavior, e.g. for when it wants certain items to be skipped w/o the user having to exclude them via #9084 (comment).

For an alternative, I also considered pushing the active target into persisted keys before performing the scroll, but the information of just the target element is often times not enough to reliably scroll to a "stable" position, when snap properties are not uniformly applied. It's better to have items self report scroll & snap properties, similar to how we update their actual size in a virtualizer.

Without these classes, all remaining responsibilities would still have to be managed somehow, which I think we would always do in the state layer thereby requiring some form of abstraction from the DOM. This insight made scroll observation largely appear similar to virtualizer to me, hence why i modeled after it. One determines which items are in view, the other determines which of the items in view are currently active.


</div>

With this infrastructure in place, developers can leverage native CSS properties — `scroll-behavior`, `scroll-padding`, `scroll-margin`, `scroll-snap-align` and `scroll-snap-type` — to customize scroll targets and define how their areas shall align and be scrolled into view when selected. Each time the scroll offset is changed, the default algorithm of a `ScrollDelegate` will determine the active target by performing a nearest area search for the current scroll offset. When multiple targets are equally aligned to their target position (e.g. a section and its first/last child), the rotation controls will display **multiple** targets as selected.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to clarify, this means the scroll delegate will essentially report the section and its first/last child as the current "in view" items in the context of a carosel?

Copy link
Contributor Author

@nwidynski nwidynski Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, although rather "active" than "in view". "Active" meaning the picker controls would show their dots as selected, if present in the picker collection.

The display of multiple active targets can also be approached in different ways. Either we carry a multi select list state for rotation controls or only report the "deepest" collection item as active and then simply render parents as selected if a child is active. I think a multi select list state kind of makes more sense, because ScrollDelegate could theoretically report a different active target for each axis.


</div>

To wrap this section up, here is an explanatory diagram to recap the functionality of the `@react-stately/rotator` package. It displays a carousel with a subset of collection keys as possible scroll targets (`slide-1`, `section-2`, `slide-4`) and a custom area on the current target. Once again, note that rotation controls, such as the picker and buttons, may **always** act on available targets, rather than items of the controlled collection.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not quite sure I understand the nuance between the available targets versus the items of the controlled collection here, mind expanding on this?

Copy link
Contributor Author

@nwidynski nwidynski Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the RAC API proposed here, we got 2 collections being hoisted up and synchronized:

  • the controlled collection (aka. listbox or slideshow)
  • the picker control collection.

The later is being used to filter the controlled, which results in our "available targets" collection (if picker is not present, the entire controlled collection is forwarded).

This "available targets" collection is what constructs the ListState in the outer Carousel, which is passed as the delegate to ScrollManager, resulting in all rotation controls only acting on these targets.

Selecting a subset of items for rotation controls can simply be done by rendering certain ids only in the picker. The next/previous buttons will be disabled as soon as the last/first available target is determined as the active target by the ScrollDelegate. This is super helpful when only wanting to rotate on either sections or items for example, or when wanting to skip certain items in rotation (e.g section headers or disabled items).

It also solves a common problem in multi-view carousels, in which we might have targets that can never be scrolled to (because min/max scroll offset is already reached). Since we wouldn't want these items to appear as available dots, we can simply measure the items, determine which ones can never be reached, and remove them from the available targets collection.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, thanks for the explanation. Having the picker collection filter the listbox/slideshow is interesting and certainly gives the user a great deal of flexibility in how they'd like the slide show controls to work and what slides they'd want to actually expose to the user regardless of what the listbox/slideshow actually contains.

However, in practice would these primarily always be section headers/disabled items like you mentioned? If so, would we need this degree of flexibility or could be have a single collection and have those section header/disabled item nodes contain information within themselves that the picker could then use to create a filtered subcollection? I'm primarily concerned about the possible complexity with synchronizing those 2 collections.

It also solves a common problem in multi-view carousels, in which we might have targets that can never be scrolled to (because min/max scroll offset is already reached)

I'm not 100% sure what this means, does this imply that there might be additional slides beyond the "last" one offered by the picker that the user shouldn't be able to get to?

Copy link
Contributor Author

@nwidynski nwidynski Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, in practice would these primarily always be section headers/disabled items like you mentioned?

Certain node types could definitely self-opt-out from being included in the picker collection. I'm thinking custom node types, loaders, section headers, and basically anything that can be statically analyzed. Where I'm thinking this could be difficult is with a more dynamic state that doesn't live on the node type, as is the case with disabled.

Additionally, a user may select to show either only items, or only sections, which may make it difficult to filter statically, since both node types are valid candidates for certain use cases.

If so, would we need this degree of flexibility

Furthermore to the use cases above, a user may want to configure his multi-view carousel to scroll "one page" at a time. Having very granular control of picker elements, means we can dynamically measure how many items are in a page and then offer one dot per "visual" page - even when items are of different sizes.

Maybe we can find a way to cut down on the exposed flexibility, while remaining with granular control internally though. I will put some thought into that 👍

does this imply that there might be additional slides beyond the "last" one offered by the picker that the user shouldn't be able to get to?

Imagine a multi-view carousel (e.g. 3 items in visible rect), which is scrolled to 100%. When determining the active target based on start alignment, the active target will always be the item at n-2, while item n-1 and n can never be active. Showing these items as picker dots may only make sense if there is further scroll offset after latest item (e.g. scroll-padding) so that they could become the active.

This flexibility would allow us to dynamically measure and remove these items from the picker collection.

I'm primarily concerned about the possible complexity with synchronizing those 2 collections.

Maybe my wording in the comment above was unfortunate. It is of course only one collection being hoisted up by CollectionBuilder, which is then split into the 2 required collections based on the internal prefix described in the RFC. This has worked pretty straightforward so far in the exploration, but let me know if you or the team got any specific concerns.

<SlidePicker id="4"><Dot size={8}/></SlidePicker>
</SlidePickerList>

<SlideShow shouldForceMount style={{overflow: 'scroll'}}>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, our swippable tabs example has this shouldForceMount applied on each TabPanel individually via a dynamic renderer, but I suppose a API like this enforces the all or nothing distinction you make below.

Copy link
Contributor Author

@nwidynski nwidynski Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, exactly. I figured that a differentiation on an individual basis would not really make sense in a carousel, although its definitely not exclusive.

The API design effectively forces you to use SlideShow for when you want a tabbed carousel, regardless of whether or not you (force)mount into a scroll container. Only when you want a presentational carousel is when you switch to a ListBox or GridList.


For our final design section, we turn to the most ambitious feature of a native carousel implementation: **Infinite mode**. This mode, commonly seen on streaming platforms and hero banners, is used to continuously showcase new or dynamic content and primarily ships in two layout styles:

- **Unordered** - An endlessly revolving list of slides, where the user can scroll seamlessly and the collection appears to wrap at its respective boundaries.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just to clarify, is this describing infinite scroll + wrapping where hitting the end of your data will loop back to the first slide? If yes, then I'm not quite sure the difference between this and "hierarchical".

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh never mind, I think I understand from the diagram below.

  • unordered => it always gives the impression of a loop
  • hierachical => it initially appears to be like a list (defined start) but then transitions to the infinite loop upon hitting the last bit of data

Copy link
Contributor Author

@nwidynski nwidynski Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep :) Although it doesn't have to be the last bit of data. Netflix for example does this in their "Top 10" display, where they want you to explore at least one page before transitioning to unordered infinite.

My ideal way to implement this would be with a sentinel, but that may be challenging to place in JSX.

Copy link
Contributor Author

@nwidynski nwidynski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LFDanLu Thank you and the entire team so much for carving out some time, especially when extra low on spare cycles!

I hope my answers cover the reasons as to why these extra delegates are necessary, but feel free to reach out again once you've given it another pass. To address the two remaining questions of how a minimal implementation could look like and how much I implemented of this already:

As you may be able to imagine, writing this detailed of an RFC required me to explore implementation already, so yes - i've given pretty much all of what is proposed here a rough pass already. These explorations have produced the following PR's for the roadblocks I've encountered. I've started to split these out and have polished them, as they should be universally applicable, regardless of this RFC:

Additionally, I've singled out some of the difficulties of implementing this RFC in the "Backwards compatibility" section, of which the DOMLayoutDelegate problem is by far the most difficult, because of the way the dependencies are structured (my hope is this will be rendered easier with the dependency RFC).

In general, what I've figured from my first implementation pass is that this RFC does not really require modifications to the existing codebase and can largely built on top of what is there today, given a few distinct adjustments. What guided the RFC here is rather my take on solving the following requirements:

  • Support for multi-view, interactive card carousels
  • Support for virtualization and async loading
  • Support for infinite looping
  • Must have low impact on bundle size
  • Must be accessible

Prior to this RFC, we've basically implemented our way through 2 iterations of a carousel, which you've picked up on in some of the things I've shared.

The first one was from scratch using a custom fork of useSelectableCollection, which has prompted the discovery of the event leaks. This solution duplicated a lot of code and would've been hard to maintain. It also only supported the presentational style, while we've had design constraints to support both styles.

The second was based on the tabs pattern, but expanded for infinite looping via fake DOM elements inserted through a custom renderer, which caused focus problems. It also had issues with ARIA semantics, especially in virtualized scenarios, effectively only supporting tabbed style.

The third iteration is now based on virtualizer and layout, which was the groundwork to this RFC. It is written to sit on top of the entire existing collection logic and was designed to be very easy to up/downgrade, while supporting all constraints and use-cases. All ARIA semantics of gridlist and listbox as well as their focus restoration is maintained, thereby not prompting any new accessibility patterns besides for controls.

A migration between cross fading and a scroll container is effectively just:

Carousel.tsx (pseudo-code, tabbed)

let state = useListState();

<button slot="next" onClick={() => state.selectNext()} />

Carousel.tsx (pseudo-code, tabbed, scroll container)

let state = useListState();
let scrollManager = useScrollManagerState({ delegate: state.selectionManager });

<button slot="next" onClick={() => scrollManager.scrollNext()} />

To answer which parts could be dropped when only supporting tabbed, I need to know whether that shall include support for mounting into a scroll container or not. As long as scroll containers shall be supported, then it doesn't make much of a difference as far as the core implementation goes, because the entire @react-aria/rotator package would have to be implemented either way.

If that‘s not the case, then we can skip the rotator package for a first pass. Of course features like infinite mode could always be dropped also. As part of this RFC, I already scoped out a rough roadmap, which you find at the bottom.

Comment on lines +122 to +124
- **Single-view transitions** — only one slide is visually emphasized at a time. This may be implemented with fading, crossfading, direct replacement, or even a scroll-based presentation where multiple slides are force-mounted, but only the currently selected slide is highlighted.

- **Multi-view transitions** — multiple slides are perceivable at once, typically arranged in a scroll container where adjacent slides remain partially or fully visible.
Copy link
Contributor Author

@nwidynski nwidynski Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to be sure, are you referencing carousel hooks or rotator hooks? The following TLDR; will hopefully make it easier to reason about their distinct scopes:

  • @react-aria/rotator -> A more advanced ScrollView, with built-in scrollIntoView & a playback delegate to answer "which scroll target comes next when i press the next button?"

  • @react-aria/carousel -> W3C spec, event wiring for playback controls & a live announcer for the active target.

Now I don’t think we would want to put an entirely different W3C spec inside of another package, so I figure you are referring to the rotation part? In any scenario,the existing listbox and gridlist hooks continue to be run next to the ones we add here, and we do rely on them. Hopefully I can make this clearer by providing the hook representation for a virtualized presentational carousel:

<useCarousel & useListState & useScrollManagerState>              // => <Carousel />
    <useButton slot="previous" />
    <useButton slot="next" />

    <useSlidePickerList>                                          // => <SlidePickerList /> 
        <useSlidePicker id="1" />
        <useSlidePicker id="2" />
        <useSlidePicker id="3" />
    </>

    <useVirtualizerState>                                         // => <Virtualizer />
        <useSelectableCollection & useListState & useScrollState> // => <ListBox />
            <useSelectableItem & useScrollTarget id="1" />
            <useSelectableItem & useScrollTarget id="2" />
            <useSelectableItem & useScrollTarget id="3" />
        </>
    </>
</>

The only reason for why useTab can not also be re-used here for pickers is because it's not really built for virtualization of panels, enforces a specific JSX placement and requires a tab list state instead of a list state.

I could only see this integrate even deeper if the @react-aria/selection package were to internalize the @react-aria/rotator package, replacing the functionality of scrollIntoView. I purposefully decided not to do that, because I felt like the selection package is already complicated enough as is.

For further clarification, please also reference these sections in the RFC (copy & paste the links, click doesnt work for highlight)


### Scroll containers

At a high-level, `@react-stately/rotator` will implement a lightweight scroll and snapping observer based on the CSS Overflow Module Level 3 and CSS Scroll Snap Module Level 1 specification. Since scroll destinations are tightly coupled to layout information, the package's architecture will feel similar to the existing `Layout` and `Virtualizer` implementation. More specifically, the RFC proposes the addition of the following new classes:
Copy link
Contributor Author

@nwidynski nwidynski Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Primarily yes, although they also take on a couple other major responsibilities:

  • determining when rotation control state is updated (eager or lazy)
  • calculating the active scroll target(s) based on layout and collected ScrollArea data
  • determining the playback order of targets when controls are engaged, aka. "which item do we scroll to when hitting next/previous"
  • allowing for easy extension/customization, similar to different layouts

The classes are designed to be very computationally light, since they do not stay in sync with layout, but rather just call the layout for the current state when rotation is scheduled or observed. Extension capabilities are especially useful for a component to customize its own rotation behavior, e.g. for when it wants certain items to be skipped w/o the user having to exclude them via #9084 (comment).

For an alternative, I also considered pushing the active target into persisted keys before performing the scroll, but the information of just the target element is often times not enough to reliably scroll to a "stable" position, when snap properties are not uniformly applied. It's better to have items self report scroll & snap properties, similar to how we update their actual size in a virtualizer.

Without these classes, all remaining responsibilities would still have to be managed somehow, which I think we would always do in the state layer thereby requiring some form of abstraction from the DOM. This insight made scroll observation largely appear similar to virtualizer to me, hence why i modeled after it. One determines which items are in view, the other determines which of the items in view are currently active.


</div>

With this infrastructure in place, developers can leverage native CSS properties — `scroll-behavior`, `scroll-padding`, `scroll-margin`, `scroll-snap-align` and `scroll-snap-type` — to customize scroll targets and define how their areas shall align and be scrolled into view when selected. Each time the scroll offset is changed, the default algorithm of a `ScrollDelegate` will determine the active target by performing a nearest area search for the current scroll offset. When multiple targets are equally aligned to their target position (e.g. a section and its first/last child), the rotation controls will display **multiple** targets as selected.
Copy link
Contributor Author

@nwidynski nwidynski Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, although rather "active" than "in view". "Active" meaning the picker controls would show their dots as selected, if present in the picker collection.

The display of multiple active targets can also be approached in different ways. Either we carry a multi select list state for rotation controls or only report the "deepest" collection item as active and then simply render parents as selected if a child is active. I think a multi select list state kind of makes more sense, because ScrollDelegate could theoretically report a different active target for each axis.


</div>

To wrap this section up, here is an explanatory diagram to recap the functionality of the `@react-stately/rotator` package. It displays a carousel with a subset of collection keys as possible scroll targets (`slide-1`, `section-2`, `slide-4`) and a custom area on the current target. Once again, note that rotation controls, such as the picker and buttons, may **always** act on available targets, rather than items of the controlled collection.
Copy link
Contributor Author

@nwidynski nwidynski Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the RAC API proposed here, we got 2 collections being hoisted up and synchronized:

  • the controlled collection (aka. listbox or slideshow)
  • the picker control collection.

The later is being used to filter the controlled, which results in our "available targets" collection (if picker is not present, the entire controlled collection is forwarded).

This "available targets" collection is what constructs the ListState in the outer Carousel, which is passed as the delegate to ScrollManager, resulting in all rotation controls only acting on these targets.

Selecting a subset of items for rotation controls can simply be done by rendering certain ids only in the picker. The next/previous buttons will be disabled as soon as the last/first available target is determined as the active target by the ScrollDelegate. This is super helpful when only wanting to rotate on either sections or items for example, or when wanting to skip certain items in rotation (e.g section headers or disabled items).

It also solves a common problem in multi-view carousels, in which we might have targets that can never be scrolled to (because min/max scroll offset is already reached). Since we wouldn't want these items to appear as available dots, we can simply measure the items, determine which ones can never be reached, and remove them from the available targets collection.

<SlidePicker id="4"><Dot size={8}/></SlidePicker>
</SlidePickerList>

<SlideShow shouldForceMount style={{overflow: 'scroll'}}>
Copy link
Contributor Author

@nwidynski nwidynski Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, exactly. I figured that a differentiation on an individual basis would not really make sense in a carousel, although its definitely not exclusive.

The API design effectively forces you to use SlideShow for when you want a tabbed carousel, regardless of whether or not you (force)mount into a scroll container. Only when you want a presentational carousel is when you switch to a ListBox or GridList.


For our final design section, we turn to the most ambitious feature of a native carousel implementation: **Infinite mode**. This mode, commonly seen on streaming platforms and hero banners, is used to continuously showcase new or dynamic content and primarily ships in two layout styles:

- **Unordered** - An endlessly revolving list of slides, where the user can scroll seamlessly and the collection appears to wrap at its respective boundaries.
Copy link
Contributor Author

@nwidynski nwidynski Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep :) Although it doesn't have to be the last bit of data. Netflix for example does this in their "Top 10" display, where they want you to explore at least one page before transitioning to unordered infinite.

My ideal way to implement this would be with a sentinel, but that may be challenging to place in JSX.


## Features

A carousel can be built using `<div role="tab">` and `<div role="tabpanel">` HTML elements, but this only supports one perceivable element at a time. Carousel helps you build accessible multi-view rotation components that can be styled as needed.
Copy link
Contributor Author

@nwidynski nwidynski Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully answered by the other comments, but TLDR; A presentational carousel is basically just a useSelectableCollection which has given up control over its scroll offset to ScrollManager.

ARIA semantics of the controlled collection are largely left untouched, depending on how we decide to model the relationship between picker dot and controlled item/scroll target. As you already suspected, the essence of a carousel lies in the ARIA semantics of controls and their announcement of scroll/visibility state.


For screen readers and keyboard users, the **tabbed** implementation strategy of a carousel is mostly straightforward, as it is largely covered by the guidelines of the [ARIA Carousel Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) and [ARIA Tabs Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/). It does also not matter whether we present a carousel in a **multi-view** animation model or not, as long as only one slide is active at a time and it's ensured to be persisted in the DOM (e.g. when virtualized).

Only minor adjustments to this pattern are required to support optional picker elements and prevent duplicate announcements within the slideshow’s live region. Both issues are addressed by loosening the strict labeling relationship between picker and panel, instead labeling pickers with their corresponding panels - an approach already validated by the [APG Tabbed Carousel Example](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/examples/carousel-2-tablist/).
Copy link
Contributor Author

@nwidynski nwidynski Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@snowystinger Re: #8630 (review)

TLDR; I'm not entirely sure whether or not I will need #8630 for Carousel, since its use is dependent on the following implementation decision. The latest exploration has rendered it obsolete, but that is to be taken with a grain of salt, as implementation hasn't finalized.

I was hoping to discuss this with the team in a draft PR, once and if the RFC is accepted in the first place. My first exploration made use of #8630, although I've since found alternative approaches.

The design challenge for a Carousel is to establish the following ARIA relationship (red). This is to be done not only for aria-controls, which we discussed in #8699, but also for aria-label/aria-labeledby purposes of each picker <-> slide pair in both tabbed and presentational scenarios.

Image

For reference, here is the relevant excerpt from the W3C Carousel pattern spec for Picker elements:

The accessible name of each picker indicates which slide it will display by including the name or number of the slide, e.g., "Slide 3". Slide names are preferable if each slide has a unique name.

So far, I have explored 3 different implementation strategies, of which the last one is the most recent. I will try to note key findings of each strategy down here for later reference:

  1. Link each pair directly, which requires us to determine the id of any given node in a collection, e.g. ListBox or GridList, without access to its internals. This strategy prompted me to explore a rework of the id generation of collection components, in which I deprecated the id parameter of collection item hooks and used the collectionId instead. This presented a challenge for useMenuItem and useSubmenuTrigger, which rely on the id parameter and don't share collection state.

  2. Instead of linking pairs directly, we point to a presentation wrapper rendered around each slide by carousels collection renderer. Since this strategy lifts id generation out of the controlled collection, we no longer need to rework the id generation of items, but instead we may now have to deal with focus issues as JAWS users could try to follow the aria-controls relationship.

  3. In the filtering phase to split our hoisted collection into slide and picker collection, we may attach the slide node as context to the picker node. This allows us to render both nodes independently, either with Slide <index> as the accessible name of the picker or optionally with a synchronized textValue/aria-label, pulled from our context node. This has proven the most straightforward solution so far, only having to deal with possible double announcements.

@devongovett
Copy link
Member

Hi @nwidynski thanks for writing this up and for your patience while we review it! As there is significant complexity here, I want to understand the use cases that led to this design as well as what we're missing in existing components.

From my perspective, there are a few different UIs represented here:

  1. A carousel with a single page visible at once, with dots representing the page, implemented using the aria tab pattern like this APG example. Something like that can be built today using our existing Tabs component. Here's a quick prototype.
  2. A single page visible at once, with next/previous buttons similar to this APG example. This seems like a new pattern, but pretty straightforward. Only one item needs to be in the DOM at a time.
  3. A single page in the viewport at once, but you can scroll/swipe to the next/previous item. I would build this using our ListBox or GridList components. Virtualization should be possible using the existing Virtualizer with a custom layout. Here's a super simple prototype.
  4. Multiple items in view at once, basically just a horizontally scrolling list. Again we can build this with ListBox/GridList. Could use Virtualizer or not. We would need to add horizontal orientation to ListLayout. Is this still considered to be a carousel at all, or is it just a list?

The parts we're missing seem to be:

  • Automatic rotation – seems fairly straightforward to control the selectedKey on a timer
  • Infinite/repeating layouts – similar to what I implemented here for Calendar feat: Add CalendarCarousel component #9079. Virtualizer doesn't support doing this in both directions at the moment. Not sure if it should or if it should be a different component specific to this use case since it would add a bunch of complexity there.
  • Virtualization for TabPanels? Or something similar to it where only one panel on each side is rendered (with shouldForceMount) so swiping still works.
  • What else?

I'm open to adding new components to simplify the API, but it would be nice to reuse as much as possible from our existing components internally. What do you think would be the minimal set of additions to make this work?

A lot of the RFC is about new types of collection components, and new internals that feel quite similar to Virtualizer. Do you think that's required or could we build on top of Tabs/ListBox/GridList/Virtualizer with a few additions like I mentioned above?

@nwidynski
Copy link
Contributor Author

nwidynski commented Feb 7, 2026

@devongovett Hey, thanks for taking the time, and don't worry about the wait! Also, excuse this RFC being a bit more implementation-oriented than it probably should be. It was requested as a way to move forward with the bunch of open PRs I have pending, so I felt it needed to be more detailed than high-level.

As a result, I think some of these questions have come up with Dan before as well and were already answered, but since all of this information by now is fairly scattered, I’ll attempt to provide a high-level view here so we’re on the same page.

First up, to answer your questions about the use case behind this design: our design sheet defines a bi-directionally scroll-/swipe-able single- AND/OR multi-card view with interactive content, virtualization, infinite/repeating layouts, and support for both picker and button controls. If you’re asking for a visual example, I think the product recommendation carousels on https://adidas.com come fairly close, but I will include specific design diagrams for how these variants will primarily play out in our UI - both presentational (img 1) and tabbed (img 2 & 3) variants - including a scrollbar for whenever swipe interactions shall be supported.

Presentational Carousel

Tabbed Carousel (Cross Fade) v2

Tabbed Carousel (Scrollable) v3

As you already noted, we can set aside auto-rotation and button controls for the sake of this discussion - although they do introduce more complexity than might first appear (e.g., handling timer interruptions on interactions). That said, these controls are mostly just syntactic sugar for a picker, with some additional ARIA requirements. The general premise is that once random access is solved, sequential access is trivial.

When abstracted, a picker compatible with all these UI variants, will always require to maintain an "active item" state that supports mutation via random key access. This state-keeping logic, as well as the associated controls used to mutate that state, are the essence of what this RFC defines as a Carousel. In other words, it is not defined by how items are displayed (single vs. multiple) or by the animation model used to transition between active items (visibility vs. scroll container), if that makes sense.

In line with that idea, the W3C Spec for the Carousel pattern largely defines requirements for control elements, not for slides or the slide container. For instance, the sole requirement for a slide is that it provide a valid ARIA role and unique accessible name - something we can already guarantee with every tabpanel as well as listbox & gridlist item.

This means, that for the remaining interactivity and accessibility requirements of slides themselves, the Carousel pattern primarily defers to an underlying collection pattern - most commonly the Tabs pattern - to handle presentation. The RFC expands upon that idea by supporting delegation not only to Tabs but also scroll containers (for example via the ListBox/GridList pattern), enabling a Carousel that uses snap scrolling as its animation & control model. Visually speaking, this would look something like this (red for Carousel):

Carousel

Unfortunately, most of the complexity comes exactly from designing a picker state layer that is able to both observe and control a scroll containers offset. This is what the proposed @react-aria/rotator package is designed to do and is why it fundamentally feels very familiar to the @react-aria/virtualizer package: they are both maintaining a state based on scroll position.

Supporting a tabs-like UI based on inert or display:none on top of these capabilities is comparatively trivial, as you've begun exploring in the small prototype. For example, this is how pseudo-code for a migration between animation models (visibility vs. scroll container) in a tabbed carousel would look like:

let state = useListState();

<button slot="next" onClick={() => state.selectNext()} />

<div id="slideshow">
  // foreach slide in slides
  <div id=`slide-{node.key}` style={{display: node.key !== state.selectedKey ? 'none' : undefined}} />
</div>
let state = useListState();
let scrollManager = useScrollManagerState({ delegate: state.selectionManager });

<button slot="next" onClick={() => scrollManager.scrollNext()} />

<div id="slideshow" style={{overflow: 'scroll'}}>
  // foreach slide in slides
  <div id=`slide-{node.key}` inert={node.key !== state.selectedKey} />
</div>

After that, the only remaining work is to make controls ARIA-spec compliant, which is what the @react-aria/carousel package is meant for.

None of the proposed hooks aim to re-implement any part of the existing pattern(s) - they solely aim to supplement and hand off to them once a desired scroll or visibility state is reached. The majority of these hooks will either go onto control elements, or will be injected onto the underlying collection items via Carousel's collection renderer, in a similar fashion to Virtualizer(Item). Hopefully, that makes it clear why a deeper integration into the existing collection hooks isn't possible - this RFC is already relying on them for everything they do today and they are exactly the reason for why react-aria would be able to offer a full-fledge carousel library in under 5 KB of gzipped bundle increase 🚀

I hope that answers the bigger part of your questions in terms of how this changeset makes use of what is there today. Before going into what is currently missing, just a quick recap on the UI variants you mentioned: those are mostly correct, though they do miss a couple permutations. The key misconceptions, from what I take away, is that:

1) a picker is supposed to be supported in all of these variants and not only in the first.
2) all these variants are to be supported through a mutual, compositional API surface

Now as to the remaining part of your question for what is missing today, apart from the proposed packages in the RFC and what you've already described as missing. Here are the specific requirements, including a small description:

  • Horizontal layout: As you already said, since ListBox/GridList are to serve as the basis to a scrollable, multi item view Carousel, we need to extend ListLayout with support for horizontal orientation. I've opened Feat: Add support for horizontal orientation to GridList & ListBox #8533 to do that.

  • Tabs vs. SlideShow: We would like to reuse the existing collection implementations as much as possible instead of re-implementing a pattern. As of today Tabs enforces a specific JSX placement, does not support virtualization of panels, and does not use a generic list state. I would opt to re-implement Tabs in a SlideShow component for this use-case rather than migrate it in a way that is backwards-compatible.

  • CollectionNode: Since @react-aria/rotator hooks supplement the existing collection hooks, they do not need to know about the implementation specifics of the collection they are attached to. Instead, they are to be injected via Carousels CollectionRender and solely work of the nodes ref, similar to a Virtualizer(Item). As of today, this would cause problems with virtualized collections, since renderers can't easily be inherited. I've opened feat: Add CollectionNode to collection renderer #8523 to add that.

  • Generic scroll observation: Requiring support for a picker in all implementations, means we need to observe a Scrollport, in both unvirtualized and virtualized scenarios. As of today, this is only supported in virtualized scroll containers via useScrollView. I've opened feat: Generic scroll observation and improved virtualization support #9115 to refactor that.

  • ScrollIntoView: Since ListBox/GridList are to be styled as a snap-enabled scroll container, their focus autoscroll should also adhere to the common CSS properties found within a Snapport. As of today, scrollIntoView does not respect scroll-margin. I've opened chore: Revert "Revert "fix: scrollIntoView should respect scroll-margin (#8715)""  #9146 to fix that.

  • DOMLayoutDelegate: Our carousel implementation should support a controlled scroll to a random collection key. As of today, this is difficult for non-virtualized scenarios because DOMLayoutDelegate is tightly coupled to the selection hooks, meaning it does not register collection nodes which aren't relevant for selection (e.g. headers). A fix for that is pending and described briefly in the "Backwards compatibility" section of this RFC.

  • Visual labeling: Slide's are often times shipping with a visual label, such as a figure caption. As of today, neither ListBoxItem nor GridListItem come with an option to render a Label. The fix is outstanding.

  • Grid edit mode/Event leaks: Our cards require support for nested interactive widgets, most prominently a carousel within a carousel. As of today, the event leak situation makes that impossible, although we don't see that as a strict blocker to this RFC. A fix for that is also pending anyways for this year, right?

  • Miscellaneous: A couple small updates to what virtualizer hooks are able too pass through in terms of styling as well as some other tiny changes are necessary to make this work. Although do take the outstanding work section with a grain of salt as I've haven't gone to the entire length yet during exploration.

  • FAQ Disclaimer: Depending on the answers to the questions in the F.A.Q section of this RFC, some additional work may be required to unblock. That is tbd.

Lastly, to explain how a minimal implementation of this RFC would look like, I will simply quote what I shared with Dan before, if that's fine:

To answer which parts could be dropped when only supporting tabbed, I need to know whether that shall include support for mounting into a scroll container or not. As long as scroll containers shall be supported, then it doesn't make much of a difference as far as the core implementation goes, because the entire @react-aria/rotator package would have to be implemented either way.

If that‘s not the case, then we can skip the rotator package for a first pass. Of course features like infinite mode could always be dropped also. As part of this RFC, I already scoped out a rough roadmap, which you find at the bottom.

PS: Infinite/Repeating layouts would be shipped through an extended ListLayout called a CarouselLayout. Within my exploration for this feature I did not have to touch Virtualizer implementation much at all, since that layout can just offset the layout infos as well as move the actual visible rect through virtualizer's delegate. I solely had to extend the virtualizer update cycle with a scrollingChanged flag so the layout can do its thing when idle.

The only real challenge is in how to deal with collection nodes possibly showing up multiple times in the DOM, which I guess we would face regardless of whether or not this is being done through Virtualizer or not.

@LFDanLu
Copy link
Member

LFDanLu commented Feb 12, 2026

Thank you for the consolidated reply, it was very helpful summarizing all the bits and pieces from disparate conversations. Just to clarify the following:

To answer which parts could be dropped when only supporting tabbed, I need to know whether that shall include support for mounting into a scroll container or not. As long as scroll containers shall be supported, then it doesn't make much of a difference as far as the core implementation goes, because the entire @react-aria/rotator package would have to be implemented either way.

If that‘s not the case, then we can skip the rotator package for a first pass. Of course features like infinite mode could always be dropped also. As part of this RFC, I already scoped out a rough roadmap, which you find at the bottom.

If we weren't to include support for mounting into a scroll container (aka supporting the in-place slide replacement model only), the first pass would omit both the @react-aria/rotator and @react-stately/rotator layer, most/any supplementary layout/virtualizer changes, etc. For the most part, the first part would then consist of the react-aria/carousel package, which includes announcements, event handling for the next/prev controls + slide picker, and wiring the aria attributes of the picker to the slides themselves. Does that roughly fit? As an aside, it seems like this would may possible be quite a small amount of changes since as you've alluded to previously, the carousel aria spec is actually quite spares in terms of requirements.

Personally, I'd prefer this first pass approach, even though it would greatly reduce the breadth of support (scrolling carousels definitely seem to be the majority of use cases in the wild). My own hang up currently is trying to digest the "whys" that necessitate the bulk of the changes. I feel like I get the complexity arising from needing to support tracking/updating the active item based on scroll position on top of the tabs like control scheme based of your explanation, but I lack the "implementation experience" that came from your own implementation of this component pattern to be fully convinced of the path forward. I feel it will be beneficial to the team and I to have the base carousel w/ ARIA implemented to play around with before drilling down to the complexities with virtualization and scrolling.

That being said, again, I'm not quite sure just how much that first pass approach I mentioned would actually help haha. It might yield something too straightforward/too divergent from the implementation with the rotator layer, but hopefully it helps move us along.

@nwidynski
Copy link
Contributor Author

@LFDanLu Great to hear it did help!

For the most part, the first part would then consist of the react-aria/carousel package, which includes announcements, event handling for the next/prev controls + slide picker, and wiring the aria attributes of the picker to the slides themselves. Does that roughly fit?

Yes! Also everything required for auto play, e.g. usePlayToggleState() & useAutoRotationState().

That being said, again, I'm not quite sure just how much that first pass approach I mentioned would actually help haha. It might yield something too straightforward/too divergent from the implementation with the rotator layer, but hopefully it helps move us along.

As you've already noted, it would exclude a large part of the audience, but if that's what it takes to get this through you guys in increments, then that's what i'll do. As an upside, I think it wont diverge too far from the rotator layer, if I'm thoughtful about how to extract that version from my exploration. There will still remain a couple intricacies worth discussing in that part of the implementation (e.g., the picker filtering we talked about).

I suggest we wait until Devon is back from vacation to give his opinion on the matter, before I tackle that though. The majority of my next week is already assigned on another task anyways. In the meantime, I would really appreciate, if we could possibly tackle some of the mentioned PRs, since juggling these constantly throughout my explorations is becoming a bit of a pain. With my latest comment, it should be apparent as to where these come in and what specific issues they address. Even if the rotator package never sees the light of day in the react-aria codebase, these changes would still enable my team to build what this RFC proposes, without maintaining a bunch of patches.

Especially the CollectionNode PR has already been approved for implementation, is needed even in the first approach, and has just been idling with a last question at this point. I hope that makes sense!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: ✏️ To Groom

Development

Successfully merging this pull request may close these issues.

3 participants