A vertically stacked set of collapsible containers allowing users to toggle content visibility.
1'use client';
2import {
3 motion,
4 AnimatePresence,
5 Transition,
6 Variants,
7 Variant,
8 MotionConfig,
9} from 'motion/react';
10import { cn } from '@/lib/utils';
11import React, { createContext, useContext, useState, ReactNode } from 'react';
12
13export type AccordionContextType = {
14 expandedValue: React.Key | null;
15 toggleItem: (value: React.Key) => void;
16 variants?: { expanded: Variant; collapsed: Variant };
17};
18
19const AccordionContext = createContext<AccordionContextType | undefined>(
20 undefined
21);
22
23function useAccordion() {
24 const context = useContext(AccordionContext);
25 if (!context) {
26 throw new Error('useAccordion must be used within an AccordionProvider');
27 }
28 return context;
29}
30
31export type AccordionProviderProps = {
32 children: ReactNode;
33 variants?: { expanded: Variant; collapsed: Variant };
34 expandedValue?: React.Key | null;
35 onValueChange?: (value: React.Key | null) => void;
36};
37
38function AccordionProvider({
39 children,
40 variants,
41 expandedValue: externalExpandedValue,
42 onValueChange,
43}: AccordionProviderProps) {
44 const [internalExpandedValue, setInternalExpandedValue] =
45 useState<React.Key | null>(null);
46
47 const expandedValue =
48 externalExpandedValue !== undefined
49 ? externalExpandedValue
50 : internalExpandedValue;
51
52 const toggleItem = (value: React.Key) => {
53 const newValue = expandedValue === value ? null : value;
54 if (onValueChange) {
55 onValueChange(newValue);
56 } else {
57 setInternalExpandedValue(newValue);
58 }
59 };
60
61 return (
62 <AccordionContext.Provider value={{ expandedValue, toggleItem, variants }}>
63 {children}
64 </AccordionContext.Provider>
65 );
66}
67
68export type AccordionProps = {
69 children: ReactNode;
70 className?: string;
71 transition?: Transition;
72 variants?: { expanded: Variant; collapsed: Variant };
73 expandedValue?: React.Key | null;
74 onValueChange?: (value: React.Key | null) => void;
75};
76
77function Accordion({
78 children,
79 className,
80 transition,
81 variants,
82 expandedValue,
83 onValueChange,
84}: AccordionProps) {
85 return (
86 <MotionConfig transition={transition}>
87 <div className={cn('relative', className)} aria-orientation='vertical'>
88 <AccordionProvider
89 variants={variants}
90 expandedValue={expandedValue}
91 onValueChange={onValueChange}
92 >
93 {children}
94 </AccordionProvider>
95 </div>
96 </MotionConfig>
97 );
98}
99
100export type AccordionItemProps = {
101 value: React.Key;
102 children: ReactNode;
103 className?: string;
104};
105
106function AccordionItem({ value, children, className }: AccordionItemProps) {
107 const { expandedValue } = useAccordion();
108 const isExpanded = value === expandedValue;
109
110 return (
111 <div
112 className={cn('overflow-hidden', className)}
113 {...(isExpanded ? { 'data-expanded': '' } : {'data-closed': ''})}
114 >
115 {React.Children.map(children, (child) => {
116 if (React.isValidElement(child)) {
117 return React.cloneElement(child, {
118 ...child.props,
119 value,
120 expanded: isExpanded,
121 });
122 }
123 return child;
124 })}
125 </div>
126 );
127}
128
129export type AccordionTriggerProps = {
130 children: ReactNode;
131 className?: string;
132};
133
134function AccordionTrigger({
135 children,
136 className,
137 ...props
138}: AccordionTriggerProps) {
139 const { toggleItem, expandedValue } = useAccordion();
140 const value = (props as { value?: React.Key }).value;
141 const isExpanded = value === expandedValue;
142
143 return (
144 <button
145 onClick={() => value !== undefined && toggleItem(value)}
146 aria-expanded={isExpanded}
147 type='button'
148 className={cn('group', className)}
149 {...(isExpanded ? { 'data-expanded': '' } : {'data-closed': ''})}
150 >
151 {children}
152 </button>
153 );
154}
155
156export type AccordionContentProps = {
157 children: ReactNode;
158 className?: string;
159};
160
161function AccordionContent({
162 children,
163 className,
164 ...props
165}: AccordionContentProps) {
166 const { expandedValue, variants } = useAccordion();
167 const value = (props as { value?: React.Key }).value;
168 const isExpanded = value === expandedValue;
169
170 const BASE_VARIANTS: Variants = {
171 expanded: { height: 'auto', opacity: 1 },
172 collapsed: { height: 0, opacity: 0 },
173 };
174
175 const combinedVariants = {
176 expanded: { ...BASE_VARIANTS.expanded, ...variants?.expanded },
177 collapsed: { ...BASE_VARIANTS.collapsed, ...variants?.collapsed },
178 };
179
180 return (
181 <AnimatePresence initial={false}>
182 {isExpanded && (
183 <motion.div
184 initial='collapsed'
185 animate='expanded'
186 exit='collapsed'
187 variants={combinedVariants}
188 className={className}
189 >
190 {children}
191 </motion.div>
192 )}
193 </AnimatePresence>
194 );
195}
196
197export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
198