Accessible Component Series: Common Patterns - Accordions
Aaron Cohen
Posted on January 10, 2022
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 };
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 };
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 };
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 };
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
Feedback
I always welcome feedback. If you spot any errors or omissions, please let me know.
Posted on January 10, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.