Add accessible Carousel component
See original GitHub issueWAI-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:
- Carousel – Similar to Reach UI’s Dialog, it would just wrap the 2 children below
- CarouselControls – Contains Rotation Control, Next Slide Control, Previous Slide Control and Slide Picker Controls
- CarouselRotator – Touch-controllable scroll container with a hidden scrollbar
- CarouselSlide – Holds content to be presented
I’ve already started building the foundation of some components above, mostly by following a CSS-Tricks article about Scroll Snap:
CarouselRotator
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
.
CarouselSlide
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.
declarations.d.ts
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 {};
Issue Analytics
- State:
- Created 4 years ago
- Reactions:20
- Comments:57 (19 by maintainers)
Top GitHub Comments
Any update on Carousel component?
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
IntersectionObserver
polyfill is loaded conditionally through a dynamic import, evading network overhead for users of evergreen browsers.🚀Usage examples
Basic
Advanced
Please see the implementation of
Carousel
for further details.📖 Reference
As seen in the advanced usage guide above, the implementation consists of:
Carousel
CarouselContainer
CarouselOverlay
CarouselPlayToggleIconButton
CarouselStepIconButton
x2CarouselRotator
CarouselSlide
Following the philosophy of Reach UI,
Carousel
serves as a convenient wrapper around theCarouselContainer
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 establishingCarouselContext
, and returns aBox
witharia-roledescription="carousel"
. The carousel’s state can be accessed through a publicly available hook calleduseCarouselControls
, offering high-level data and methods:isInfinite
isPlaying
,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 ofCarouselContainer
. Its descendants can be overridden naturally by providingchildren
.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 thearia-label
oraria-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!