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.

Code

// Accordion.tsx
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