Accessible React accordion component
Tags: a11y, react, javascript, webdev
Published: 2019-05-30 21:40:45

---
title: Accessible React accordion component
published: true
description: Small experiment on how straightforward it would be to write React accessible accordion component. I have no prior experience in accessibility
tags: [a11y, react, js, webdev]
cover_image: https://thepracticaldev.s3.amazonaws.com/i/y6wbt5zs4w8rnqcn45wf.png
series: Accessible React
---

Small experiment (which turned in to tutorial) on how straightforward it would be to write React accessible accordion component. I have no prior experience in accessibility (well, maybe some basic things, like use alt param don't use links as buttons).

I followed [Accordion Design Pattern in WAI-ARIA Authoring Practices 1.1](https://www.w3.org/TR/wai-aria-practices/examples/accordion/accordion.html) and nothing else.

The focus of this tutorial is a11y and React, so we will not care about how to pack to npm or CSS-in-JS or anything else. The easiest way to start in this case is with create-react-app.

### Bootstrap

Let's bootstrap our project

```
npx create-react-app my-app
cd my-app
npm start
```

[Remove all unrelated things](https://github.com/stereobooster/react-accessible-accordion/commit/6664bb290e5d68f415bd236202c61a091a5cc80e).

### Design API

It's time to think about the API of the component. Let's take a look at typical accordion:

![](https://thepracticaldev.s3.amazonaws.com/i/i8ca76wwevbvyewidy8v.png)

There are a root component and sections. Each section has a title and content. Right? Based on this we can imagine how our API will look like:

```js
const App = () => (
  <Accordion>
    <AccordionSection title="section 1">content 1</AccordionSection>
    <AccordionSection title="section 2" expanded>
      content 2
    </AccordionSection>
  </Accordion>
);
```

Let's write first draft of the components for given API

```js
import React from "react";

export const Accordion = ({ children }) => <div>{children}</div>;

export const AccordionSection = ({ children, title, expanded }) => (
  <>
    <div>{title}</div>
    <div>{expanded && children}</div>
  </>
);
```

### Add a11y

Nice. Let's open [Accordion Design Pattern in WAI-ARIA Authoring Practices 1.1](https://www.w3.org/TR/wai-aria-practices/examples/accordion/accordion.html) and copy-paste all required markup:

```jsx
export const AccordionSection = ({ children, title, expanded, id }) => {
  const sectionId = `section-${id}`;
  const labelId = `label-${id}`;

  return (
    <>
      <div
        role="button"
        aria-expanded={expanded}
        aria-controls={sectionId}
        id={labelId}
        tabIndex={0}
      >
        {title}
      </div>
      <div
        role="region"
        aria-labelledby={labelId}
        id={sectionId}
        hidden={!expanded}
      >
        {expanded && children}
      </div>
    </>
  );
};
```

`Accordion` doesn't need to change. There are two elements: Header and Panel. Header (`role="button"`) has `id` and `aria-controls` (`id` of corresponding Panel). Panel (`role="region"`) has `id` and `aria-labelledby` (`id` of corresponding Header). `aria-expanded` if the section is expanded or not. `hidden` opposite of if the section is expanded or not. Pretty straightforward IMO.

[Let's add some styles](https://github.com/stereobooster/react-accessible-accordion/commit/2eb6ad679485339213d08e8c9c887faab5ecae10).

![](https://thepracticaldev.s3.amazonaws.com/i/fn7hcxim89iqbr7ptbxy.png)

[Now it's time to add state and event handling](https://github.com/stereobooster/react-accessible-accordion/commit/8f014094701860a879efa7179223af282590e980).

State and callback (we are creating [controlled component](https://reactjs.org/docs/forms.html#controlled-components)):

```jsx
function App() {
  const [expanded1, setExpanded1] = useState(false);
  return (
      <Accordion>
        <AccordionSection
      ...
          expanded={expanded1}
          onToggle={() => setExpanded1(!expanded1)}
        >
```

Event handling

```jsx
export const AccordionSection = ({
  ...
  expanded,
  onToggle
}) => {
  ...
  return (
    <>
      <div
        role="button"
        ...
        onClick={onToggle}
        onKeyDown={e => {
          switch (e.key) {
            case " ":
            case "Enter":
              onToggle();
              break;
            default:
          }
        }}
      >
```

### Let's pause

At this point, it is already a pretty good result. We fulfill half of the requirements for a11y, with not so much of work.

- <kbd>Space</kbd> or <kbd>Enter</kbd>
  - When focus is on the accordion header of a collapsed section, expands the section.
- <kbd>Tab</kbd>
  - Moves focus to the next focusable element.
  - All focusable elements in the accordion are included in the page Tab sequence.
- <kbd>Shift</kbd> + <kbd>Tab</kbd>
  - Moves focus to the previous focusable element.
  - All focusable elements in the accordion are included in the page Tab sequence.

If you do at least this, it would be already better than nothing.

### More a11y

[Next section](https://github.com/stereobooster/react-accessible-accordion/commit/3d894bf54170b0ad0b643058118b615876250ea2) is a bit more complicated (more fun).

- <kbd>Down Arrow</kbd>
  - When focus is on an accordion header, moves focus to the next accordion header.
  - When focus is on last accordion header, moves focus to first accordion header.
- <kbd>Up Arrow</kbd>
  - When focus is on an accordion header, moves focus to the previous accordion header.
  - When focus is on first accordion header, moves focus to last accordion header.

To do this we need to track where is the focus, to be able to select next or previous section. We need to store this as variable once per each accordion. So maybe `useState`? Well, but we don't want to trigger re-render of the component when focus changes. Then `useRef` I guess.

```jsx
export const Accordion = ({ children }) => {
  const focusRef = useRef(null);
```

`focusRef` will contain either `id` of a currently focused section or `null` in case none is selected. We need to track `focus` and `blur` events on Headers.

```jsx
<div
  role="button"
  ...
  onFocus={() => {
    focusRef.current = id;
  }}
  onBlur={() => {
    focusRef.current = null;
  }}
```

So how do we pass `focusRef` from `Accordion` down to `AccordionSection`? We can do this via props (with `React.Childre.map` and `React.CloneElement`) or we can do this with Context. I like Context idea more because it creates more clean API.

Create context:

```jsx
const AccordionContext = createContext({
  focusRef: {}
});
export const useAccordionContext = () => useContext(AccordionContext);
```

Pass `focusRef` to `Context` (I use `useMemo` to make sure we don't trigger unwanted rerenders due to updates in `Context`)

```jsx
const context = useMemo(
  () => ({
    focusRef
  }),
  []
);

return (
  <AccordionContext.Provider value={context}>
    {children}
  </AccordionContext.Provider>
);
```

And in `AccordionSection`

```js
const { focusRef } = useAccordionContext();
```

Ok, this way we can capture currently selected section. Now we need to respond to keyboard events, let's add a handler in the root component:

```jsx
export const AccordionSection = ({}) => {
  ...
  return (
    <div
      onKeyDown={e => {
        switch (e.key) {
          case "ArrowDown":
            break;
          case "ArrowUp":
            break;
          case "Home":
            break;
          case "End":
            break;
        }
      }}
    >
      <AccordionContext.Provider value={context}>
```

In the case of `ArrowDown` we need to find the `focus` element in `children` and select the next one. We can get an array of all `id`s of `children` element

```jsx
const ids = React.Children.map(children, child => child.props.id);
```

then find an index of the focused element

```jsx
const index = ids.findIndex(x => x === focusRef.current);
```

then find next value

```jsx
if (index >= ids.length - 1) {
  return ids[0];
} else {
  return ids[index + 1];
}
```

Good. But how we will actually trigger a change of focus? 🤔
We can use [`focus()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus) method for this. To get a DOM Element we will need to use reference.

```jsx
export const AccordionSection = ({}) => {
  const labelRef = useRef();
  ...
  return (
    <>
      <div
        role="button"
        ...
        ref={labelRef}
      >
```

As well we will need to use `useEffect` to actually call a method on a DOM Element. The question is when to trigger this effect? We need to trigger it every time selection of the tab changes, every time the user triggers `ArrowDown` or `ArrowUp` etc. So we need to store it in some variable and trigger effect every time it changes

```jsx
export const AccordionSection = ({}) => {
  ...
  useEffect(() => {
    if (id === selected && labelRef.current) {
      labelRef.current.focus();
    }
  }, [id, selected]);
```

every time selection changes and the selected item is the same as the current one put the focus on it.

Where do we store `selected` value? In the root, because we need one variable per `Accordion`. How do we pass it? Through Context, the same way as we passed `focusRef`. In `Accordion`:

```jsx
const focusRef = useRef(null);
const [selected, setSelected] = useState(null);
const context = useMemo(
  () => ({
    focusRef,
    selected
  }),
  [selected]
);
...
case "ArrowDown":
  {
    const ids = React.Children.map(children, child => child.props.id);
    const index = ids.findIndex(x => x === focusRef.current);
    if (index >= ids.length - 1) {
      setSelected(ids[0]);
    } else {
      setSelected(ids[index + 1]);
    }
  }
```

and in `AccordionSection`:

```jsx
const { focusRef, selected } = useAccordionContext();
```

Phew! We made it. Fully accessible component. Don't forget to add logic for

- Home
  - When focus is on an accordion header, moves focus to the first accordion header.
- End
  - When focus is on an accordion header, moves focus to the last accordion header.

### Developer Experience

We took care of users let's take care of developers. We heavily rely on `id`s, in case the developer will forget to provide it they will get very subtle errors. [Let's check if it is present and warn otherwise](https://github.com/stereobooster/react-accessible-accordion/commit/45237580410f3e671e34347be43a306d75560dbf):

```jsx
AccordionSection.propTypes = {
  id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
  title: PropTypes.string.isRequired,
  expanded: PropTypes.bool,
  onToggle: PropTypes.func
};
```

As well we assume that `id`s are unique, [let's check it too](https://github.com/stereobooster/react-accessible-accordion/commit/8edb4dfcaaf38fa59e8f50534ec0711ca991cdb1):

```jsx
if (process.env.NODE_ENV === "development") {
  const uniqueIds = new Set();
  React.Children.forEach(children, child => {
    if (uniqueIds.has(child.props.id)) {
      console.warn(
        `AccordionSection id param should be unique, found the duplicate key: ${
          child.props.id
        }`
      );
    } else {
      uniqueIds.add(child.props.id);
    }
  });
}
```

As of now, API of our component requires onToggle callback which supposes to be bound to its id or be unique for each section. This API is hard to use. [Let's instead pass `id` to callback](https://github.com/stereobooster/react-accessible-accordion/commit/37131d743e145f6cad25f12779782f4377061f1b). This way developer can use one store and one callback for all sections:

```jsx
const [expanded, setExpanded] = useState({ "2": true });
const toggle = id => {
  setExpanded({
    ...expanded,
    [id]: !expanded[id]
  });
};
...
<AccordionSection
  title="section 1"
  id="1"
  expanded={expanded["1"]}
  onToggle={toggle}
>
...
<AccordionSection
  title="section 2"
  id="2"
  expanded={expanded["2"]}
  onToggle={toggle}
```

I don't like that we need to repeat `expanded` and `onToggle` for each section, instead, we can pass it once to `Accordion`:

```jsx
<Accordion expanded={expanded} onToggle={onToggle}>
  <AccordionSection title="section 1" id="id1">
  ...
  </AccordionSection>
  <AccordionSection title="section 2" id="id2">
  ...
  </AccordionSection>
</Accordion>
```

It looks cleaner this way. As well there are some drawbacks like you need to make sure that ids in state and ids in `AccordionSection`s are the same (otherwise some section may not work).

We can go even further and [provide a custom hook for default behavior](https://github.com/stereobooster/react-accessible-accordion/commit/04d9070aa797f7b1f180dce3f9ce558850c9fd72).

```jsx
import { useState } from "react";

export const useAccordionState = intialState => {
  const [expanded, setExpanded] = useState(intialState);
  const onToggle = id => {
    setExpanded({
      ...expanded,
      [id]: !expanded[id]
    });
  };
  return { expanded, onToggle };
};
```

So the final code will look like this:

```jsx
function App() {
  const accordionProps = useAccordionState({ });
  return (
      <Accordion {...accordionProps}>
        <AccordionSection title="section 1" id="id1">
```

### Conclusion

It wasn't that scary as I thought. WAI-ARIA Authoring Practices are well written 👏. I encourage you to use appropriate markup and keyboard events (every time you use `onClick`). Implementing fully accessible components can be a fun learning exercise.

Online demo is [here](https://stereobooster.github.io/react-accessible-accordion/). Full source code is [here](https://github.com/stereobooster/react-accessible-accordion).

## PS

If I won't be lazy and if this post will have an interest I will write about how to test this component with Cypress and how to fix one sneaky bug that I noticed after I wrote the post.