DEV Community

Aaron Cohen
Aaron Cohen

Posted on

Accessible Component Series: Common Patterns - Accordions

Intro

About this series

Web accessibility is often overlooked by developers. As such, this series is intended to serve as how-two for developers to implement commonly used patterns with accessibility in mind.

We'll be using React, Typescript and Framer Motion throughout this series.

I have another post, available here, highlighting a number of reasons why I think developers should put more emphasis on accessibility.

If you're interested in finding other articles in this series, you can refer to this post which I'll continue to update as new posts go live.

What we're building

In this post, I'll be going through the ins-and-outs of building an Accordion. Common usages include FAQs, Product Description Sections, etc..

Assumptions

This post assumes knowledge of JavaScript, React, and a tiny bit of TypeScript. Even if you're not up to speed on Typescript, you should have no issue following along. We'll also be using Framer Motion to improve the UX of our animations.

A quick note on Accessibility + ARIA Attributes

It's incredibly important to how understand how and why specific ARIA Attributes are being used. ARIA Attributes, when used incorrectly, can potentially make a user's experience even worse.


TL;DR

If you want to dive right in and take a look under the hood, you can check out the final result on CodeSandbox or toy with the finished version below:


🔧 Let's get building

1. The Accordion Container

First, let's setup our base Accordion component:

// src/components/Accordion.tsx

import React from "react";

import { AccordionItem } from "components/Accordion/AccordionItem";

// Component Props
interface Props {
  defaultIndex?: number;
  sections: Array<{
    title: string;
    body: string;
  }>;
}

const Accordion: React.FC<Props> = ({ defaultIndex = -1, sections = [] }) => {
  // Used to track the currently active (open) accordion item
  // Note: If we pass in -1, all items will be closed by default
  const [activeIndex, setActiveIndex] = React.useState(defaultIndex);

  // A handler for setting active accordion item
  const handleSetActiveIndex = (n: number) => {
    // If the user clicks the active accordion item, close it
    if (n === activeIndex) setActiveIndex(-1);
    // Otherwise set the clicked item to active
    else setActiveIndex(n);
  };

  return (
    <ul className="accordion">
      {sections.map((s, idx) => (
        <AccordionItem
          key={s.title}
          item={s}
          idx={idx}
          activeIndex={activeIndex}
          handleClick={handleSetActiveIndex}
        />
      ))}
    </ul>
  );
};

export { Accordion };


Enter fullscreen mode Exit fullscreen mode

Nothing special or out of the ordinary here. Simply tracking state via activeIndex and iterating over our sections, passed in via props, and returning our AccordionItem component defined in the next step below.

2. The Accordion Item

// src/components/Accordion/AccordionItem.tsx

import React from "react";
import { AnimatePresence, useReducedMotion, m } from "framer-motion";

import { SVG } from "components/SVG";

// Component Props
interface Props {
  idx: number;
  activeIndex: number;
  item: { title: string; body: string };
  handleClick: (n: number) => void;
}

const AccordionItem: React.FC<Props> = ({
  item,
  idx,
  activeIndex,
  handleClick
}) => {
  // Get browser's reduce motion setting
  const shouldReduceMotion = useReducedMotion();
  // Active State
  const active = idx === activeIndex;
  // Button ID : Must be unique to each accordion.
  const buttonId = `button-${idx}`;
  // Panel ID : Must be unique to each accordion
  const panelId = `panel-${idx}`;

  // Framer Motion Variants
  const variants = {
    active: { height: "auto", marginTop: "1rem" },
    inactive: { height: 0, marginTop: "0rem" }
  };

  // If browser's reduce motion settings are true, respect them otherwise use default animation
  const transition = shouldReduceMotion ? { type: "just" } : undefined;

  return (
    <li className="accordion__item">
      <button
        id={buttonId}
        // Aria Controls - Denotes what element this element controls
        aria-controls={panelId}
        // Aria Expanded - Denotes the expanded state of the element this element controls
        aria-expanded={active}
        // On Click, pass the index back up to the parent component
        onClick={() => handleClick(idx)}
      >
        <span className="t-heading">{item.title}</span>
        <SVG.PlusMinus active={active} />
      </button>
      <AnimatePresence>
        {active && (
          <m.div
            id={panelId}
            // Aria Labelled By - Denotes what element this element is controlled by
            aria-labelledby={buttonId}
            initial={"inactive"}
            animate={"active"}
            exit={"inactive"}
            variants={variants}
            transition={transition}
          >
            <p>{item.body}</p>
          </m.div>
        )}
      </AnimatePresence>
    </li>
  );
};

export { AccordionItem };


Enter fullscreen mode Exit fullscreen mode

Here we're getting into some real accessibility-related topics, namely the use of aria-controls, aria-expanded, and aria-labelledby. Links for further information are found in the Accessibility Resources & References section below.

In short, we're using some IDs, unique to this list, to create relationships between button elements and div elements. This is a bit of a contrived example and if this were to be used in production, it would be wise to ensure IDs are unique to the entire page to avoid conflicts.

We're also using a few helpers from Framer Motion. The useReducedMotion hook to helps us decide which animation to use when transitioning between states. The AnimatePresence component helps us smoothly mount and un-mount a given accordion panel.

3. SVG Indicator

// src/components/SVG/PlusMinus.tsx

import React from "react";
import { m, useReducedMotion } from "framer-motion";

const variants = {
  active: { rotate: 90 },
  inactive: { rotate: 0 }
};

interface SVGProps {
  className?: string;
  active: boolean;
}

const PlusMinus: React.FC<SVGProps> = ({ className = "", active = false }) => {
  // Get browser's reduce motion setting
  const shouldReduceMotion = useReducedMotion();

  // If browser's reduce motion settings are true, respect them otherwise use default animation
  const transition = shouldReduceMotion ? { type: "just" } : undefined;
  return (
    <m.svg
      className={className}
      width="12"
      height="12"
      viewBox="0 0 12 12"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
    >
      <m.line
        x1="6"
        y1="-4.37114e-08"
        x2="6"
        y2="12"
        stroke="currentColor"
        strokeWidth="2"
        animate={active ? "active" : "inactive"}
        variants={variants}
        transition={transition}
      />
      <m.line y1="6" x2="12" y2="6" stroke="currentColor" strokeWidth="2" />
    </m.svg>
  );
};

export { PlusMinus };

Enter fullscreen mode Exit fullscreen mode

While this component isn't critical to the function or accessibility of the accordion, it's a slick little indicator that helps us assign a visual cue to the state of our accordion items.

4. Adding some data

The last thing to do is add some data. In this example, we're passing in some hard-coded placeholder data to the Accordion component via App.tsx

// src/App.tsx
import React from 'react';
import { Accordion } from "components/Accordion";

const data = [
  {
    title: "Section One",
    body:
      "Lorem ipsum dolor sit amet, consectetur adipiscing elit.Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Tincidunt vitae semper quis lectus nulla. Risus nullam eget felis eget nunc lobortis. Cum sociis natoque penatibus et magnis dis parturient montes nascetur."
  },
  {
    title: "Section Two",
    body:
      "Dolor morbi non arcu risus quis varius quam. Leo duis ut diam quam. Leo duis ut diam quam nulla porttitor massa id neque. Vel elit scelerisque mauris pellentesque pulvinar pellentesque habitant morbi. Pretium vulputate sapien nec sagittis aliquam malesuada bibendum arcu."
  }
];

const App = () => {
  return <Accordion sections={data} />;
};

export { App };

Enter fullscreen mode Exit fullscreen mode

And that's that.

If you're interested in seeing how things are styled in my setup, check out the CodeSandbox


Closing Notes

Accessibility Resources & References

MDN Aria Attributes

Feedback

I always welcome feedback. If you spot any errors or omissions, please let me know.

Oldest comments (0)