A flexible and easy-to-use carousel with customizable navigation and indicators.
1'use client';
2import {
3 Children,
4 ReactNode,
5 createContext,
6 useContext,
7 useEffect,
8 useRef,
9 useState,
10} from 'react';
11import { motion, Transition, useMotionValue } from 'motion/react';
12import { cn } from '@/lib/utils';
13import { ChevronLeft, ChevronRight } from 'lucide-react';
14
15export type CarouselContextType = {
16 index: number;
17 setIndex: (newIndex: number) => void;
18 itemsCount: number;
19 setItemsCount: (newItemsCount: number) => void;
20 disableDrag: boolean;
21};
22
23const CarouselContext = createContext<CarouselContextType | undefined>(
24 undefined
25);
26
27function useCarousel() {
28 const context = useContext(CarouselContext);
29 if (!context) {
30 throw new Error('useCarousel must be used within an CarouselProvider');
31 }
32 return context;
33}
34
35export type CarouselProviderProps = {
36 children: ReactNode;
37 initialIndex?: number;
38 onIndexChange?: (newIndex: number) => void;
39 disableDrag?: boolean;
40};
41
42function CarouselProvider({
43 children,
44 initialIndex = 0,
45 onIndexChange,
46 disableDrag = false,
47}: CarouselProviderProps) {
48 const [index, setIndex] = useState<number>(initialIndex);
49 const [itemsCount, setItemsCount] = useState<number>(0);
50
51 const handleSetIndex = (newIndex: number) => {
52 setIndex(newIndex);
53 onIndexChange?.(newIndex);
54 };
55
56 useEffect(() => {
57 setIndex(initialIndex);
58 }, [initialIndex]);
59
60 return (
61 <CarouselContext.Provider
62 value={{
63 index,
64 setIndex: handleSetIndex,
65 itemsCount,
66 setItemsCount,
67 disableDrag,
68 }}
69 >
70 {children}
71 </CarouselContext.Provider>
72 );
73}
74
75export type CarouselProps = {
76 children: ReactNode;
77 className?: string;
78 initialIndex?: number;
79 index?: number;
80 onIndexChange?: (newIndex: number) => void;
81 disableDrag?: boolean;
82};
83
84function Carousel({
85 children,
86 className,
87 initialIndex = 0,
88 index: externalIndex,
89 onIndexChange,
90 disableDrag = false,
91}: CarouselProps) {
92 const [internalIndex, setInternalIndex] = useState<number>(initialIndex);
93 const isControlled = externalIndex !== undefined;
94 const currentIndex = isControlled ? externalIndex : internalIndex;
95
96 const handleIndexChange = (newIndex: number) => {
97 if (!isControlled) {
98 setInternalIndex(newIndex);
99 }
100 onIndexChange?.(newIndex);
101 };
102
103 return (
104 <CarouselProvider
105 initialIndex={currentIndex}
106 onIndexChange={handleIndexChange}
107 disableDrag={disableDrag}
108 >
109 <div className={cn('group/hover relative', className)}>
110 <div className='overflow-hidden'>{children}</div>
111 </div>
112 </CarouselProvider>
113 );
114}
115
116export type CarouselNavigationProps = {
117 className?: string;
118 classNameButton?: string;
119 alwaysShow?: boolean;
120};
121
122function CarouselNavigation({
123 className,
124 classNameButton,
125 alwaysShow,
126}: CarouselNavigationProps) {
127 const { index, setIndex, itemsCount } = useCarousel();
128
129 return (
130 <div
131 className={cn(
132 'pointer-events-none absolute left-[-12.5%] top-1/2 flex w-[125%] -translate-y-1/2 justify-between px-2',
133 className
134 )}
135 >
136 <button
137 type='button'
138 aria-label='Previous slide'
139 className={cn(
140 'pointer-events-auto h-fit w-fit rounded-full bg-zinc-50 p-2 transition-opacity duration-300 dark:bg-zinc-950',
141 alwaysShow
142 ? 'opacity-100'
143 : 'opacity-0 group-hover/hover:opacity-100',
144 alwaysShow
145 ? 'disabled:opacity-40'
146 : 'group-hover/hover:disabled:opacity-40',
147 classNameButton
148 )}
149 disabled={index === 0}
150 onClick={() => {
151 if (index > 0) {
152 setIndex(index - 1);
153 }
154 }}
155 >
156 <ChevronLeft
157 className='stroke-zinc-600 dark:stroke-zinc-50'
158 size={16}
159 />
160 </button>
161 <button
162 type='button'
163 className={cn(
164 'pointer-events-auto h-fit w-fit rounded-full bg-zinc-50 p-2 transition-opacity duration-300 dark:bg-zinc-950',
165 alwaysShow
166 ? 'opacity-100'
167 : 'opacity-0 group-hover/hover:opacity-100',
168 alwaysShow
169 ? 'disabled:opacity-40'
170 : 'group-hover/hover:disabled:opacity-40',
171 classNameButton
172 )}
173 aria-label='Next slide'
174 disabled={index + 1 === itemsCount}
175 onClick={() => {
176 if (index < itemsCount - 1) {
177 setIndex(index + 1);
178 }
179 }}
180 >
181 <ChevronRight
182 className='stroke-zinc-600 dark:stroke-zinc-50'
183 size={16}
184 />
185 </button>
186 </div>
187 );
188}
189
190export type CarouselIndicatorProps = {
191 className?: string;
192 classNameButton?: string;
193};
194
195function CarouselIndicator({
196 className,
197 classNameButton,
198}: CarouselIndicatorProps) {
199 const { index, itemsCount, setIndex } = useCarousel();
200
201 return (
202 <div
203 className={cn(
204 'absolute bottom-0 z-10 flex w-full items-center justify-center',
205 className
206 )}
207 >
208 <div className='flex space-x-2'>
209 {Array.from({ length: itemsCount }, (_, i) => (
210 <button
211 key={i}
212 type='button'
213 aria-label={`Go to slide ${i + 1}`}
214 onClick={() => setIndex(i)}
215 className={cn(
216 'h-2 w-2 rounded-full transition-opacity duration-300',
217 index === i
218 ? 'bg-zinc-950 dark:bg-zinc-50'
219 : 'bg-zinc-900/50 dark:bg-zinc-100/50',
220 classNameButton
221 )}
222 />
223 ))}
224 </div>
225 </div>
226 );
227}
228
229export type CarouselContentProps = {
230 children: ReactNode;
231 className?: string;
232 transition?: Transition;
233};
234
235function CarouselContent({
236 children,
237 className,
238 transition,
239}: CarouselContentProps) {
240 const { index, setIndex, setItemsCount, disableDrag } = useCarousel();
241 const [visibleItemsCount, setVisibleItemsCount] = useState(1);
242 const dragX = useMotionValue(0);
243 const containerRef = useRef<HTMLDivElement>(null);
244 const itemsLength = Children.count(children);
245
246 useEffect(() => {
247 if (!containerRef.current) {
248 return;
249 }
250
251 const options = {
252 root: containerRef.current,
253 threshold: 0.5,
254 };
255
256 const observer = new IntersectionObserver((entries) => {
257 const visibleCount = entries.filter(
258 (entry) => entry.isIntersecting
259 ).length;
260 setVisibleItemsCount(visibleCount);
261 }, options);
262
263 const childNodes = containerRef.current.children;
264 Array.from(childNodes).forEach((child) => observer.observe(child));
265
266 return () => observer.disconnect();
267 }, [children, setItemsCount]);
268
269 useEffect(() => {
270 if (!itemsLength) {
271 return;
272 }
273
274 setItemsCount(itemsLength);
275 }, [itemsLength, setItemsCount]);
276
277 const onDragEnd = () => {
278 const x = dragX.get();
279
280 if (x <= -10 && index < itemsLength - 1) {
281 setIndex(index + 1);
282 } else if (x >= 10 && index > 0) {
283 setIndex(index - 1);
284 }
285 };
286
287 return (
288 <motion.div
289 drag={disableDrag ? false : 'x'}
290 dragConstraints={
291 disableDrag
292 ? undefined
293 : {
294 left: 0,
295 right: 0,
296 }
297 }
298 dragMomentum={disableDrag ? undefined : false}
299 style={{
300 x: disableDrag ? undefined : dragX,
301 }}
302 animate={{
303 translateX: `-${index * (100 / visibleItemsCount)}%`,
304 }}
305 onDragEnd={disableDrag ? undefined : onDragEnd}
306 transition={
307 transition || {
308 damping: 18,
309 stiffness: 90,
310 type: 'spring',
311 duration: 0.2,
312 }
313 }
314 className={cn(
315 'flex items-center',
316 !disableDrag && 'cursor-grab active:cursor-grabbing',
317 className
318 )}
319 ref={containerRef}
320 >
321 {children}
322 </motion.div>
323 );
324}
325
326export type CarouselItemProps = {
327 children: ReactNode;
328 className?: string;
329};
330
331function CarouselItem({ children, className }: CarouselItemProps) {
332 return (
333 <motion.div
334 className={cn(
335 'w-full min-w-0 shrink-0 grow-0 overflow-hidden',
336 className
337 )}
338 >
339 {children}
340 </motion.div>
341 );
342}
343
344export {
345 Carousel,
346 CarouselContent,
347 CarouselNavigation,
348 CarouselIndicator,
349 CarouselItem,
350 useCarousel,
351};
352