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

PropTypeDefaultDescription
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