Chakra-ui: Add accessible Carousel component

Created on 21 Oct 2019  ·  26Comments  ·  Source: chakra-ui/chakra-ui

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:

  • 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 {};
Feature 🚀

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 IntersectionObserver polyfill is loaded conditionally through a dynamic import, evading network overhead for users of evergreen browsers.

🚀Usage examples

Basic

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>
  );
}

Advanced

Please see the implementation of Carousel for further details.

📖 Reference

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:

  • 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 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!

All 26 comments

An improved structure which just came to mind:

  • [ ] Carousel – Wraps CarouselContainer along with initialized CarouselControls and CarouselRotator, passing children directly to the latter
  • [ ] CarouselContainer – Responsible for setting ARIA props, e.g. aria-roledescription to carousel
  • [ ] CarouselControls – Contains children to navigate between slides and toggle auto-rotation
  • [ ] CarouselRotator – Wraps its children into (non-public) CarouselSlide components, controlling their padding and inert attribute

I’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.

Carousel.tsx

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>
  );
}

CarouselContainer.tsx

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} />;
}

CarouselControls.tsx

import React from 'react';

// TODO: Consider renaming to `CarouselControlPanel`
export default function CarouselControls() {
  return <>{/* TODO: Add control components */}</>;
}

CarouselRotator.tsx

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>
  );
}

CarouselSlide.tsx

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.

❗ 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

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>
  );
}

Advanced

Please see the implementation of Carousel for further details.

📖 Reference

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:

  • 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 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%).

Legacy browser support is now available, along with several improvements for handling window size changes.

The latest source can be viewed from here, along with a live demo.

I’ve sent a pull request, but have to mention the following annoyances:

  • Carousel IconButtons have 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)
  • Reduced motion setting isn’t respected after server-side rendering. This is an issue with the underlying web-api-hooks library of mine.

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.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

snack-0verflow picture snack-0verflow  ·  3Comments

ChrisLusted picture ChrisLusted  ·  3Comments

ianstormtaylor picture ianstormtaylor  ·  3Comments

checkmatez picture checkmatez  ·  3Comments

feross picture feross  ·  3Comments