Accordion
A vertically stacked set of interactive headings that each reveal a section of content.
Usage
Installation
Accordion.tsx
1"use client"
2import React, {
3 useState,
4 useRef,
5 createContext,
6 useContext,
7 ReactNode,
8 useLayoutEffect,
9} from "react";
10import { cn } from "@/utils/cn";
11import { motion, AnimatePresence, Spring } from "framer-motion";
12import { ClassNameValue } from "tailwind-merge";
13import { ChevronDown } from "lucide-react";
14
15type AccordionContextType = {
16 activeIndex: number | null;
17 toggleAccordion: (index: number) => void;
18 itemVariant: "default" | "bordered" | "rounded" | "rectangle" | " ";
19 borderVariant?: boolean;
20};
21
22const AccordionContext = createContext<AccordionContextType | null>(null);
23
24interface AccordionProps {
25 children: ReactNode;
26 className?: ClassNameValue;
27 variant?: "default" | "rounded" | "rectangle";
28 border?: boolean;
29}
30
31const Accordion: React.FC<AccordionProps> = ({
32 children,
33 className,
34 variant,
35 border,
36}) => {
37 const [activeIndex, setActiveIndex] = useState<number | null>(null);
38 const toggleAccordion = (index: number) => {
39 setActiveIndex(activeIndex === index ? null : index);
40 };
41 const itemVariant = variant || " ";
42 const borderVariant = border || false;
43 return (
44 <AccordionContext.Provider
45 value={{ activeIndex, toggleAccordion, itemVariant, borderVariant }}
46 >
47 <div
48 className={cn(
49 "flex h-max flex-col items-center justify-center font-geist",
50 className,
51 )}
52 >
53 {children}
54 </div>
55 </AccordionContext.Provider>
56 );
57};
58
59interface AccordionItemProps {
60 index: number;
61 children: React.ReactNode;
62 className?: ClassNameValue;
63}
64
65const springConfig: Spring = {
66 type: "spring",
67 stiffness: 250,
68 damping: 25,
69};
70
71const AccordionItem: React.FC<AccordionItemProps> = ({
72 index,
73 children,
74 className,
75}) => {
76 const context = useContext(AccordionContext);
77 if (!context) {
78 throw new Error("AccordionItem must be used within an Accordion");
79 }
80 const { activeIndex, toggleAccordion, itemVariant, borderVariant } = context;
81 const isActive = activeIndex === index;
82 const contentRef = useRef<HTMLDivElement>(null);
83 const buttonRef = useRef<HTMLButtonElement>(null);
84 const [height, setHeight] = useState<number | string>("auto");
85
86 useLayoutEffect(() => {
87 if (contentRef.current && buttonRef.current) {
88 const buttonHeight = buttonRef.current.offsetHeight;
89 setHeight(
90 isActive
91 ? contentRef.current.scrollHeight + buttonHeight
92 : buttonHeight,
93 );
94 }
95 }, [isActive]);
96
97 return (
98 <motion.div
99 className={cn(
100 "h-max w-full overflow-hidden bg-zinc-950",
101 { "border border-white/20": borderVariant },
102 { "rounded-lg": itemVariant === "rounded" },
103 { "rounded-none": itemVariant === "rectangle" },
104 { "rounded-sm": itemVariant === "default" },
105 className,
106 )}
107
108 animate={{ height }}
109 transition={springConfig}
110 >
111 {React.Children.map(children, (child) => {
112 if (React.isValidElement(child)) {
113 if (child.type === AccordionButton) {
114 return React.cloneElement(
115 child as React.ReactElement<AccordionButtonProps>,
116 {
117 ref: buttonRef,
118 isActive: isActive,
119 onClick: () => toggleAccordion(index),
120 },
121 );
122 } else if (child.type === AccordionContent) {
123 return (
124 <div ref={contentRef} className={child.props.className}>
125 <AnimatePresence initial={false}>
126 {isActive &&
127 React.cloneElement(
128 child as React.ReactElement<AccordionContentProps>,
129 {
130 key: "content",
131 },
132 )}
133 </AnimatePresence>
134 </div>
135 );
136 }
137 }
138 return child;
139 })}
140 </motion.div>
141 );
142};
143
144interface AccordionButtonProps {
145 isActive?: boolean;
146 onClick?: () => void;
147 className?: ClassNameValue;
148 children: React.ReactNode;
149 ref?: React.Ref<HTMLButtonElement>;
150}
151
152const AccordionButton = React.forwardRef<
153 HTMLButtonElement,
154 AccordionButtonProps
155>(({ isActive, onClick, className, children }, ref) => {
156 return (
157 <button
158 ref={ref}
159 onClick={onClick}
160 className={cn(
161 "flex w-full items-center justify-between p-5 text-left font-geist font-semibold text-white transition-colors duration-300 hover:underline focus:outline-none",
162 className,
163 )}
164 >
165 <span>{children}</span>
166 <motion.div
167 animate={{ rotate: isActive ? 180 : 0 }}
168 transition={springConfig}
169 >
170 <ChevronDown className="h-6 w-6" />
171 </motion.div>
172 </button>
173 );
174});
175
176AccordionButton.displayName = "AccordionButton";
177
178interface AccordionContentProps {
179 className?: string;
180 children: React.ReactNode;
181}
182
183const AccordionContent: React.FC<AccordionContentProps> = ({
184 className,
185 children,
186}) => {
187 return (
188 <motion.div
189 key="content"
190 initial={{ opacity: 0 }}
191 animate={{ opacity: 1 }}
192 exit={{ opacity: 0 }}
193 transition={{ duration: 0.3 }}
194 className={cn(
195 "h-full w-full text-wrap px-5 py-4 font-geist text-sm font-normal text-gray-200",
196 className,
197 )}
198 >
199 {children}
200 </motion.div>
201 );
202};
203
204export { Accordion, AccordionItem, AccordionButton, AccordionContent };
Props
Accordion
The main wrapper component for the accordion.
Prop | Type | Default | Description |
---|---|---|---|
children | ReactNode | - | The accordion items. |
className | ClassNameValue | - | Additional CSS classes. |
variant | "default" | "rounded" | "rectangle" | - | The visual style of the accordion. |
border | boolean | - | Whether to show a border around the accordion. |
AccordionItem
Represents a single item in the accordion.
Prop | Type | Default | Description |
---|---|---|---|
index | number | - | The index of the item in the accordion. |
children | ReactNode | - | The content of the item. Should include AccordionButton and AccordionContent . |
className | ClassNameValue | - | Additional CSS classes. |
AccordionButton
The clickable header of an accordion item.
Prop | Type | Default | Description |
---|---|---|---|
isActive | boolean | - | Whether the item is currently expanded. |
onClick | () => void | - | Function to call when the button is clicked. |
className | ClassNameValue | - | Additional CSS classes. |
children | ReactNode | - | The content of the button. |
AccordionContent
The expandable content of an accordion item.
Prop | Type | Default | Description |
---|---|---|---|
className | string | - | Additional CSS classes. |
children | ReactNode | - | The content to display when the item is expanded. |