Accordion
I thought the accordion would’ve been a bit more challenging and at first I was having trouble until I mapped it all out in my head of linking the animation of the chevron to the appearance of the content. I’m still iterating and deciding whether I want borders on each of the items but I’m leaning towards taking them out. The reason I’m leaning towards taking these out is because as the accordion content populates, the line is pushed creating this awkward state in the UI that I’m not the biggest fan of. I think the border bottom looks great when it’s just the accordion headers but not great when content is exposed. So therefore, it will be eliminated.
Props & Styling
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | { name: string; content: string }[] | — | Ordered list of entries. Each renders a trigger button (name) and a collapsible panel (content). |
Styling
Each row is flex flex-col with a border-b border-gray-200 separator, and the trigger is flex flex-row justify-between so the chevron always sits flush right regardless of title length.
The chevron rotates via a plain animate={{ rotate: ... }} tied to activeIndex === index (-90deg when open, 0 otherwise) — no spring, so the rotation reads as a direct response to the click rather than a bounce. The panel itself animates height: 0 → 'auto' inside AnimatePresence, both at 0.2s easeOut.
Keyboard support is wired independently of the mouse interaction: ArrowUp/ArrowDown on a focused trigger move focus to the previous/next trigger (wrapping around the ends), via refs collected in buttonRefs.
content is typed as a plain string. If you need rich JSX inside a panel (links, lists, nested components), you'll need to widen that field to React.ReactNode in your own copy of the component.
Code
import { ChevronLeft } from "lucide-react";
import {AnimatePresence, motion} from 'motion/react';
import { useState, useId, useRef } from "react";
interface AccordionProps {
items: Array<{
name: string
content: string
}>
}
const MotionDiv = motion.create('div');
const MotionChevronLeft = motion.create(ChevronLeft);
const Accordion = ({ items}: AccordionProps) => {
const generatedId = useId();
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const buttonRefs = useRef<HTMLButtonElement[]>([])
const toggleAccordionItem = (index:number) => {
if(activeIndex === index) {
setActiveIndex(null);
} else {
setActiveIndex(index);
}
}
const handleKeyDown = (e: React.KeyboardEvent, index:number) => {
let nextIndex = 0;
if(e.key === 'ArrowUp'){
e.preventDefault();
nextIndex = (index - 1 + items.length) % items.length
buttonRefs.current[nextIndex].focus();
}
else if (e.key === 'ArrowDown'){
e.preventDefault();
nextIndex = (index + 1) % items.length
buttonRefs.current[nextIndex].focus();
}
}
return (
<div className = "flex flex-col w-full gap-2">
{items.map((item, index) => {
const triggerId = `${generatedId}-trigger-${index}`
const contentId = `${generatedId}-content-${index}`
return (
<div
key = {triggerId}
className = "flex flex-col justify-between py-2 border-b border-gray-200"
>
<button
ref={(el) => { if (el) buttonRefs.current[index] = el; }}
aria-controls = {contentId}
id = {triggerId}
aria-expanded = {activeIndex === index ? true : false}
onKeyDown={(e) => handleKeyDown(e, index)}
className = "flex flex-row justify-between py-1 focus-visible:rounded-md cursor-pointer"
onClick = {() => toggleAccordionItem(index)}
>
<p className = "text-gray-700 font-medium min-w-0 text-left">{item.name}</p>
<MotionChevronLeft
animate = {{rotate: activeIndex === index ? -90 : 0}}
transition = {{ease: 'easeOut', duration: 0.2}}
className="text-gray-400 w-[20px] h-[20px]"
>
</MotionChevronLeft>
</button>
<AnimatePresence>
{activeIndex === index && (
<MotionDiv
role="region"
id = {contentId}
aria-labelledby= {triggerId}
initial = {{height: 0}}
animate = {{height: 'auto'}}
exit={{ height: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className = "overflow-hidden"
>
<div className = "py-2">
<p className = "text-gray-600">
{item.content}
</p>
</div>
</MotionDiv>
)}
</AnimatePresence>
</div>
)
})}
</div>
)
}
export default Accordion