WAI-ARIA Authoring Practices has a section about building accessible carousel components:
A carousel presents a set of items, referred to as slides, by sequentially displaying a subset of one or more slides. Typically, one slide is displayed at a time, and users can activate a next or previous slide control that hides the current slide and "rotates" the next or previous slide into view. In some implementations, rotation automatically starts when the page loads, and it may also automatically stop once all the slides have been displayed. While a slide may contain any type of content, image carousels where each slide contains nothing more than a single image are common.
I think there is a great opportunity to build a composable component by following Reach UI's Philosophy. As I've observed, there are several layers of abstraction:
I've already started building the foundation of some components above, mostly by following a CSS-Tricks article about Scroll Snap:
import { Flex, FlexProps } from '@chakra-ui/core';
import React from 'react';
import { MarginProps, ResponsiveValue } from 'styled-system';
import CarouselSlide from './CarouselSlide';
// TODO: https://www.w3.org/TR/wai-aria-practices-1.1/#grouped-carousel-elements
function negateResponsiveValue<T>(value: ResponsiveValue<T>) {
if (value == null) return value;
if (typeof value === 'number') return -value;
if (typeof value === 'string') return `-${value}`;
if (Array.isArray(value)) return value.map(v => (v != null ? `${-v}` : v));
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, v != null ? `${-v}` : v]),
);
}
export interface CarouselProps extends FlexProps {
children: React.ReactComponentElement<typeof CarouselSlide>[];
slideIndex?: number;
spacing?: MarginProps['margin'];
spacingX?: MarginProps['mx'];
spacingY?: MarginProps['my'];
}
export default function Carousel({
children,
slideIndex = 0,
spacing,
spacingX,
spacingY,
...restProps
}: CarouselProps) {
return (
<Flex
as="section"
aria-roledescription="carousel"
aria-live="polite" // The carousel is NOT automatically rotating
my={negateResponsiveValue(spacingY != null ? spacingY : spacing)}
overflow="auto"
css={{
scrollSnapType: 'x mandatory',
// TODO: Leave vendor prefixing to the underlying library
'::-webkit-scrollbar': { width: 0 },
'-msOverflowStyle': 'none',
scrollbarWidth: 'none',
}}
{...restProps}
>
{React.Children.map(children, (child, i) =>
React.cloneElement(child, {
inert: i !== slideIndex ? '' : undefined,
px: spacingX != null ? spacingX : spacing,
py: spacingY != null ? spacingY : spacing,
}),
)}
</Flex>
);
}
There is still a lot of work to do, e.g. adding support for automatic rotating by setting aria-live to off.
import { Box, BoxProps } from '@chakra-ui/core';
import React from 'react';
// TODO: Follow the status of https://github.com/WICG/inert and remove polyfill
import 'wicg-inert';
export default function CarouselSlide({
children,
inert,
...restProps
}: BoxProps) {
return (
<Box
role="group"
aria-roledescription="slide"
flex="0 0 100%"
css={{ scrollSnapAlign: 'center' }}
{...restProps}
>
{/* TODO: Remove extra `div` once `shouldForwardProp` of `Box` supports `inert` */}
<div inert={inert}>{children}</div>
</Box>
);
}
The inert attribute is required to disable tab navigation to undisplayed slides.
Unfortunately, TypeScript and React don't support the inert attribute yet, thus, it cannot be specified as a boolean, but an empty string: ''.
declare module 'react' {
interface DOMAttributes<T> {
inert?: '' | undefined;
}
}
declare global {
namespace JSX {
interface IntrinsicAttributes {
inert?: '' | undefined;
}
}
}
export {};
An improved structure which just came to mind:
CarouselContainer along with initialized CarouselControls and CarouselRotator, passing children directly to the latteraria-roledescription to carouselCarouselSlide components, controlling their padding and inert attributeI’ll refactor the components above to match my new proposal, stay tuned for updates!
Good work @kripod. To help us comprehend this better, it'll be nice to share a codesandbox link as well. It might also help if you show an example of how the components will be used. Something like:
<Carousel>
<CarouselContainer>
<CarouselSlide />
</CarouselContainer>
<CarouselControls>
<CarouselForwardArrow />
<CarouselBackArrow />
</CarouselControls>
</Carousel>
Not sure if this is the right structure but it's looking exciting already. For the inert, We already use a library called aria-hidden to help make dom nodes behind a modal inert. You can check the Modal component's code to see how it works.
I look forward to more updates on this.
@segunadebayo The structure is exactly as you’ve outlined, and Carousel could be used as follows:
<Carousel slideIndex={n}>
<img src=“…” alt=“…” />
<img … />
…
</Carousel>
Also, slideIndex may be uncontrolled when desired.
As for the tip about the inert attribute, thanks for the heads-up, I’ll look into the source of Modal.
While aria-hidden (both as a library and an attribute) conceals given elements from the accessibility tree, it doesn't disable tabbing through them, in contrast to the inert attribute. If slides contain focusable or searchable elements, then the inert attribute seems to be mandatory.
I agree. If you check the code, you'll see that we used a combination of aria-hidden and react-focus-lock. However, I'm excited to see how the inert attribute solves this issue.
I've made some progress by separating concerns and adding props like infinite, autoPlay and playInterval.
import React from 'react';
import CarouselContainer from './CarouselContainer';
import CarouselControls from './CarouselControls';
import CarouselRotator, { CarouselRotatorProps } from './CarouselRotator';
export default function Carousel(props: CarouselRotatorProps) {
return (
<CarouselContainer>
<CarouselControls />
<CarouselRotator {...props} />
</CarouselContainer>
);
}
import { Box, BoxProps } from '@chakra-ui/core';
import React from 'react';
export default function CarouselContainer(props: BoxProps) {
return <Box as="section" aria-roledescription="carousel" {...props} />;
}
import React from 'react';
// TODO: Consider renaming to `CarouselControlPanel`
export default function CarouselControls() {
return <>{/* TODO: Add control components */}</>;
}
import { Flex, FlexProps } from '@chakra-ui/core';
import React from 'react';
import { MarginProps, ResponsiveValue } from 'styled-system';
import { fromEntries } from '../utils/object';
import CarouselSlide from './CarouselSlide';
// TODO: https://www.w3.org/TR/wai-aria-practices-1.1/#grouped-carousel-elements
function negateResponsiveValue<T>(value: ResponsiveValue<T>) {
if (value == null) return value;
if (typeof value === 'number') return -value;
if (typeof value === 'string') return `-${value}`;
if (Array.isArray(value)) return value.map(v => (v != null ? `${-v}` : v));
return fromEntries(
Object.entries(value).map(([k, v]) => [k, v != null ? `${-v}` : v]),
);
}
export interface CarouselRotatorProps extends FlexProps {
children: React.ReactElement[];
infinite?: boolean;
autoPlay?: boolean;
playInterval?: number;
activeIndex?: number;
spacing?: MarginProps['margin'];
spacingX?: MarginProps['mx'];
spacingY?: MarginProps['my'];
}
export default function CarouselRotator({
children,
infinite,
autoPlay,
playInterval = 5000,
activeIndex = 0,
spacing,
spacingX,
spacingY,
...restProps
}: CarouselRotatorProps) {
return (
<Flex
aria-atomic={false}
aria-live={autoPlay ? 'off' : 'polite'}
my={negateResponsiveValue(spacingY != null ? spacingY : spacing)}
overflow="auto"
css={{
scrollSnapType: 'x mandatory',
// TODO: Leave vendor prefixing to the underlying library
'::-webkit-scrollbar': { width: 0 },
'-msOverflowStyle': 'none',
scrollbarWidth: 'none',
}}
{...restProps}
>
{React.Children.map(children, (child, i) => (
// Labels are lifted up to comply with WAI-ARIA Authoring Practices
<CarouselSlide
inert={i !== activeIndex ? '' : undefined}
aria-label={child.props['aria-label']}
aria-labelledby={child.props['aria-labelledby']}
px={spacingX != null ? spacingX : spacing}
py={spacingY != null ? spacingY : spacing}
>
{React.cloneElement(child, {
'aria-label': undefined,
'aria-labelledby': undefined,
})}
</CarouselSlide>
))}
</Flex>
);
}
import { Box, BoxProps } from '@chakra-ui/core';
import React from 'react';
// TODO: Follow the status of https://github.com/WICG/inert and remove polyfill
import 'wicg-inert';
export default function CarouselSlide({
children,
inert,
...restProps
}: BoxProps) {
return (
<Box
role="group"
aria-roledescription="slide"
flex="0 0 100%"
css={{ scrollSnapAlign: 'center' }}
{...restProps}
>
{/* TODO: Remove extra `div` once `shouldForwardProp` of `Box` supports `inert` */}
<div inert={inert}>{children}</div>
</Box>
);
}
Finally, I'm ready with the first version of the Carousel, implementing the basic variant outlined in WAI-ARIA Authoring Practices 1.1. Unfortunately, I wasn't able to set up a CodeSandbox, but the code is available as a part of a project I'm working on.
A basic mobile-friendly demo is available from here.
The components are made with older browsers in mind, providing graceful fallbacks in legacy environments. The IntersectionObserver polyfill is loaded conditionally through a dynamic import, evading network overhead for users of evergreen browsers.
function Component() {
return (
<Carousel isInfinite autoPlay maxWidth="xl" mx="auto">
<Image
alt="Aerial photography of lake viewing mountain under orange skies"
src="https://images.unsplash.com/photo-1569302911021-297d2362e3d3?w=800&q=80"
/>
<Image
alt="Empty road near mountain"
src="https://images.unsplash.com/photo-1569250814530-1e923fd61bc6?w=800&q=80"
/>
<Image
alt="Person standing near waterfalls"
src="https://images.unsplash.com/photo-1569099377939-569bbac3c4df?w=800&q=80"
/>
</Carousel>
);
}
Please see the implementation of Carousel for further details.
As seen in the advanced usage guide above, the implementation consists of:
Following the philosophy of Reach UI, Carousel serves as a convenient wrapper around the CarouselContainer component, providing all the children out of the box. Most users will not have to know anything else about the details below.
CarouselContainer serves as a provider for establishing CarouselContext, and returns a Box with aria-roledescription="carousel". The carousel's state can be accessed through a publicly available hook called useCarouselControls, offering high-level data and methods:
isInfiniteisPlaying, togglePlaying()activeIndex, totalCount, goTo(index), jump(delta)With those, custom components can be built conveniently. For instance, CarouselStepIconButton was built relying solely upon the aforementioned utilities.
CarouselOverlay provides control buttons absolutely positioned on the stacking context of CarouselContainer. Its descendants can be overridden naturally by providing children.
CarouselRotator is the heart of the pack, where all the magic happens. It manages the state of slides and handles user interactions like swiping or focusing with tab. In the future, slides could be virtualized or lazy-loaded for additional performance benefits.
Each slide is wrapped inside a CarouselSlide, and shall be labeled through the aria-label or aria-labelledby prop of carousel children.
Please let me know about any questions or concerns about the implementation, I'm all open for ideas and observations!
I'm trying my best to improve browser compatibility and will emulate snapping behavior in environments without native support. I've just learned that even IE 10 can be supported by adding -ms-scroll-snap-points-x: snapInterval(0, 100%).
I’ve sent a pull request, but have to mention the following annoyances:
variantColor=“blackAlpha”, which are too transparent (0.08% visibility) in dark mode, causing a low contrast ratio. Is there a way to overcome this without having to hard-code values and overriding _hover? (@segunadebayo)Also, I’m planning to abstract the useWindowResizing logic into a generic useChanging state wrapper hook.
@kripod, you might need to override the _hover, for now.
I'll look through the code soon.
Sorry for the radio silence. I'm working on my thesis at university and using Chakra as a UI library to execute my project. I just realized that a Carousel may be controlled outside of a CarouselRotator, so controlled props may be ditched in favor of the useCarouselControls hook for improved interoperability with the inner state.
For instance, while controlling the component occasionally, we may still allow users to change slides. Also, this requires CarouselProvider to be decoupled from CarouselContainer, which would also make more sense, as we may want to control a Carousel outside of its DOM container.
WCAG 2.2.2 says
Auto-updating: For any auto-updating information that (1) starts automatically and (2) is presented in parallel with other content, there is a mechanism for the user to pause, stop, or hide it or to control the frequency of the update unless the auto-updating is part of an activity where it is essential.
Is that what disableAutoPause does? Is there a way for the user to trigger that?
@jamesarosen Actually, I have a simpler implementation in progress, but didn't manage to actualize this PR yet. disableAutoPause is for adhering to the example showcased in WAI-ARIA Authoring Practices, by setting it to true when the keyboard or mouse focus is on the pause button:
If the carousel can automatically rotate, it also:
- […]
- Stops rotating when keyboard focus enters the carousel. It does not restart unless the user explicitly requests it to do so.
- Stops rotating whenever the mouse is hovering over the carousel.
Is this component still going to be implemented or has it dropped off?
@zasuh , it's definitely going to be implemented. I really appreciate the work of @kripod on this. I just haven't had some time to review the code completely and suggest improvements before merging.
@zasuh I also made several improvements over time which are not yet included in this PR. Eagerly waiting for the TypeScript release to update typings and continue work on this. Also, I’ll have final exams at university during the first quarter of January. I had to cope with similar tasks lately, explaining why I wasn’t so active lately.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.Thank you for your contributions.
Not stale!
Looking forward in using this component because rn I have to use react-slick or my own carousel
Any updates on this ?
@corentinchap Lately, I’ve been busy building a CSS-in-JS library. The carousel implementation has to be rewritten for better type checking and performance. Unfortunately, I don’t think I’ll have time to execute this task on my own.
We'll look into this after the next release. Thanks for your hard work on this @kripod.
Hi friends! Is this happening?
Hello guys!
@kripod I really appreciate your work on this. It's really amazing how much time, energy, and considerations you put toward this! Hoping we get this real soon as I now have to resort to using react-slick.
cc @segunadebayo
@segunadebayo created a Carousel component for a separate project but it needs a little work to move it over into Chakra. This will likely be a post-1.0 feature, but it is still in the works.
Most helpful comment
Finally, I'm ready with the first version of the Carousel, implementing the basic variant outlined in WAI-ARIA Authoring Practices 1.1. Unfortunately, I wasn't able to set up a CodeSandbox, but the code is available as a part of a project I'm working on.
❗ Live demo
A basic mobile-friendly demo is available from here.
✨ Progressive enhancement
The components are made with older browsers in mind, providing graceful fallbacks in legacy environments. The
IntersectionObserverpolyfill is loaded conditionally through a dynamic import, evading network overhead for users of evergreen browsers.🚀Usage examples
Basic
Advanced
Please see the implementation of
Carouselfor further details.📖 Reference
As seen in the advanced usage guide above, the implementation consists of:
CarouselCarouselContainerCarouselOverlayCarouselPlayToggleIconButtonCarouselStepIconButtonx2CarouselRotatorCarouselSlideFollowing the philosophy of Reach UI,
Carouselserves as a convenient wrapper around theCarouselContainercomponent, providing all the children out of the box. Most users will not have to know anything else about the details below.CarouselContainerserves as a provider for establishingCarouselContext, and returns aBoxwitharia-roledescription="carousel". The carousel's state can be accessed through a publicly available hook calleduseCarouselControls, offering high-level data and methods:isInfiniteisPlaying,togglePlaying()activeIndex,totalCount,goTo(index),jump(delta)With those, custom components can be built conveniently. For instance,
CarouselStepIconButtonwas built relying solely upon the aforementioned utilities.CarouselOverlayprovides control buttons absolutely positioned on the stacking context ofCarouselContainer. Its descendants can be overridden naturally by providingchildren.CarouselRotatoris the heart of the pack, where all the magic happens. It manages the state of slides and handles user interactions like swiping or focusing with tab. In the future, slides could be virtualized or lazy-loaded for additional performance benefits.Each slide is wrapped inside a
CarouselSlide, and shall be labeled through thearia-labeloraria-labelledbyprop of carousel children.Please let me know about any questions or concerns about the implementation, I'm all open for ideas and observations!