1'use client';
2
3import * as React from 'react';
4import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
5import useEmblaCarousel, {
6 type UseEmblaCarouselType,
7} from 'embla-carousel-react';
8
9import { classNames } from '../../lib/utils';
10import { Button } from '../../components/ui/button';
11
12type CarouselApi = UseEmblaCarouselType[1];
13type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
14type CarouselOptions = UseCarouselParameters[0];
15type CarouselPlugin = UseCarouselParameters[1];
16
17type CarouselProps = {
18 opts?: CarouselOptions;
19 plugins?: CarouselPlugin;
20 orientation?: 'horizontal' | 'vertical';
21 setApi?: (api: CarouselApi) => void;
22};
23
24type CarouselContextProps = {
25 carouselRef: ReturnType<typeof useEmblaCarousel>[0];
26 api: ReturnType<typeof useEmblaCarousel>[1];
27 scrollPrev: () => void;
28 scrollNext: () => void;
29 canScrollPrev: boolean;
30 canScrollNext: boolean;
31} & CarouselProps;
32
33const CarouselContext = React.createContext<CarouselContextProps | null>(null);
34
35function useCarousel() {
36 const context = React.useContext(CarouselContext);
37
38 if (!context) {
39 throw new Error('useCarousel must be used within a <Carousel />');
40 }
41
42 return context;
43}
44
45const Carousel = React.forwardRef<
46 HTMLDivElement,
47 React.HTMLAttributes<HTMLDivElement> & CarouselProps
48>(
49 (
50 {
51 orientation = 'horizontal',
52 opts,
53 setApi,
54 plugins,
55 className,
56 children,
57 ...props
58 },
59 ref
60 ) => {
61 const [carouselRef, api] = useEmblaCarousel(
62 {
63 ...opts,
64 axis: orientation === 'horizontal' ? 'x' : 'y',
65 },
66 plugins
67 );
68 const [canScrollPrev, setCanScrollPrev] = React.useState(false);
69 const [canScrollNext, setCanScrollNext] = React.useState(false);
70
71 const onSelect = React.useCallback((api: CarouselApi) => {
72 if (!api) {
73 return;
74 }
75
76 setCanScrollPrev(api.canScrollPrev());
77 setCanScrollNext(api.canScrollNext());
78 }, []);
79
80 const scrollPrev = React.useCallback(() => {
81 api?.scrollPrev();
82 }, [api]);
83
84 const scrollNext = React.useCallback(() => {
85 api?.scrollNext();
86 }, [api]);
87
88 const handleKeyDown = React.useCallback(
89 (event: React.KeyboardEvent<HTMLDivElement>) => {
90 if (event.key === 'ArrowLeft') {
91 event.preventDefault();
92 scrollPrev();
93 } else if (event.key === 'ArrowRight') {
94 event.preventDefault();
95 scrollNext();
96 }
97 },
98 [scrollPrev, scrollNext]
99 );
100
101 React.useEffect(() => {
102 if (!api || !setApi) {
103 return;
104 }
105
106 setApi(api);
107 }, [api, setApi]);
108
109 React.useEffect(() => {
110 if (!api) {
111 return;
112 }
113
114 onSelect(api);
115 api.on('reInit', onSelect);
116 api.on('select', onSelect);
117
118 return () => {
119 api?.off('select', onSelect);
120 };
121 }, [api, onSelect]);
122
123 return (
124 <CarouselContext.Provider
125 value={{
126 carouselRef,
127 api: api,
128 opts,
129 orientation:
130 orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
131 scrollPrev,
132 scrollNext,
133 canScrollPrev,
134 canScrollNext,
135 }}
136 >
137 <div
138 ref={ref}
139 onKeyDownCapture={handleKeyDown}
140 className={classNames('relative', className)}
141 role='region'
142 aria-roledescription='carousel'
143 {...props}
144 >
145 {children}
146 </div>
147 </CarouselContext.Provider>
148 );
149 }
150);
151Carousel.displayName = 'Carousel';
152
153const CarouselContent = React.forwardRef<
154 HTMLDivElement,
155 React.HTMLAttributes<HTMLDivElement>
156>(({ className, ...props }, ref) => {
157 const { carouselRef, orientation } = useCarousel();
158
159 return (
160 <div ref={carouselRef} className='overflow-hidden'>
161 <div
162 ref={ref}
163 className={classNames(
164 'flex',
165 orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
166 className
167 )}
168 {...props}
169 />
170 </div>
171 );
172});
173CarouselContent.displayName = 'CarouselContent';
174
175const CarouselItem = React.forwardRef<
176 HTMLDivElement,
177 React.HTMLAttributes<HTMLDivElement>
178>(({ className, ...props }, ref) => {
179 const { orientation } = useCarousel();
180
181 return (
182 <div
183 ref={ref}
184 role='group'
185 aria-roledescription='slide'
186 className={classNames(
187 'min-w-0 shrink-0 grow-0 basis-full',
188 orientation === 'horizontal' ? 'pl-4' : 'pt-4',
189 className
190 )}
191 {...props}
192 />
193 );
194});
195CarouselItem.displayName = 'CarouselItem';
196
197const CarouselPrevious = React.forwardRef<
198 HTMLButtonElement,
199 React.ComponentProps<typeof Button>
200>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
201 const { orientation, scrollPrev, canScrollPrev } = useCarousel();
202
203 return (
204 <Button
205 ref={ref}
206 variant={variant}
207 size={size}
208 className={classNames(
209 'absolute h-8 w-8 rounded-full border-none bg-transparent hover:border hover:bg-neutral-200 md:h-12 md:w-12 md:p-3',
210 orientation === 'horizontal'
211 ? '-bottom-14 left-4 md:-left-16 md:top-1/2 md:-translate-y-1/2'
212 : '-top-12 left-1/2 -translate-x-1/2 rotate-90',
213 className
214 )}
215 disabled={!canScrollPrev}
216 onClick={scrollPrev}
217 {...props}
218 >
219 <ChevronLeftIcon className='relative h-6 w-6 md:right-px md:h-12 md:w-12' />
220 <span className='sr-only'>Previous slide</span>
221 </Button>
222 );
223});
224CarouselPrevious.displayName = 'CarouselPrevious';
225
226const CarouselNext = React.forwardRef<
227 HTMLButtonElement,
228 React.ComponentProps<typeof Button>
229>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
230 const { orientation, scrollNext, canScrollNext } = useCarousel();
231
232 return (
233 <Button
234 ref={ref}
235 variant={variant}
236 size={size}
237 className={classNames(
238 'absolute h-8 w-8 rounded-full border-none bg-transparent hover:border hover:bg-neutral-200 md:h-12 md:w-12 md:p-3',
239 orientation === 'horizontal'
240 ? '-bottom-14 right-4 md:-right-16 md:top-1/2 md:-translate-y-1/2'
241 : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
242 className
243 )}
244 disabled={!canScrollNext}
245 onClick={scrollNext}
246 {...props}
247 >
248 <ChevronRightIcon className='relative left-px h-6 w-6 md:h-10 md:w-10' />
249 <span className='sr-only'>Next slide</span>
250 </Button>
251 );
252});
253CarouselNext.displayName = 'CarouselNext';
254
255export {
256 type CarouselApi,
257 Carousel,
258 CarouselContent,
259 CarouselItem,
260 CarouselPrevious,
261 CarouselNext,
262};
263
1'use client';
2
3import * as React from 'react';
4import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
5import useEmblaCarousel, {
6 type UseEmblaCarouselType,
7} from 'embla-carousel-react';
8
9import { classNames } from '../../lib/utils';
10import { Button } from '../../components/ui/button';
11
12type CarouselApi = UseEmblaCarouselType[1];
13type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
14type CarouselOptions = UseCarouselParameters[0];
15type CarouselPlugin = UseCarouselParameters[1];
16
17type CarouselProps = {
18 opts?: CarouselOptions;
19 plugins?: CarouselPlugin;
20 orientation?: 'horizontal' | 'vertical';
21 setApi?: (api: CarouselApi) => void;
22};
23
24type CarouselContextProps = {
25 carouselRef: ReturnType<typeof useEmblaCarousel>[0];
26 api: ReturnType<typeof useEmblaCarousel>[1];
27 scrollPrev: () => void;
28 scrollNext: () => void;
29 canScrollPrev: boolean;
30 canScrollNext: boolean;
31} & CarouselProps;
32
33const CarouselContext = React.createContext<CarouselContextProps | null>(null);
34
35function useCarousel() {
36 const context = React.useContext(CarouselContext);
37
38 if (!context) {
39 throw new Error('useCarousel must be used within a <Carousel />');
40 }
41
42 return context;
43}
44
45const Carousel = React.forwardRef<
46 HTMLDivElement,
47 React.HTMLAttributes<HTMLDivElement> & CarouselProps
48>(
49 (
50 {
51 orientation = 'horizontal',
52 opts,
53 setApi,
54 plugins,
55 className,
56 children,
57 ...props
58 },
59 ref
60 ) => {
61 const [carouselRef, api] = useEmblaCarousel(
62 {
63 ...opts,
64 axis: orientation === 'horizontal' ? 'x' : 'y',
65 },
66 plugins
67 );
68 const [canScrollPrev, setCanScrollPrev] = React.useState(false);
69 const [canScrollNext, setCanScrollNext] = React.useState(false);
70
71 const onSelect = React.useCallback((api: CarouselApi) => {
72 if (!api) {
73 return;
74 }
75
76 setCanScrollPrev(api.canScrollPrev());
77 setCanScrollNext(api.canScrollNext());
78 }, []);
79
80 const scrollPrev = React.useCallback(() => {
81 api?.scrollPrev();
82 }, [api]);
83
84 const scrollNext = React.useCallback(() => {
85 api?.scrollNext();
86 }, [api]);
87
88 const handleKeyDown = React.useCallback(
89 (event: React.KeyboardEvent<HTMLDivElement>) => {
90 if (event.key === 'ArrowLeft') {
91 event.preventDefault();
92 scrollPrev();
93 } else if (event.key === 'ArrowRight') {
94 event.preventDefault();
95 scrollNext();
96 }
97 },
98 [scrollPrev, scrollNext]
99 );
100
101 React.useEffect(() => {
102 if (!api || !setApi) {
103 return;
104 }
105
106 setApi(api);
107 }, [api, setApi]);
108
109 React.useEffect(() => {
110 if (!api) {
111 return;
112 }
113
114 onSelect(api);
115 api.on('reInit', onSelect);
116 api.on('select', onSelect);
117
118 return () => {
119 api?.off('select', onSelect);
120 };
121 }, [api, onSelect]);
122
123 return (
124 <CarouselContext.Provider
125 value={{
126 carouselRef,
127 api: api,
128 opts,
129 orientation:
130 orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
131 scrollPrev,
132 scrollNext,
133 canScrollPrev,
134 canScrollNext,
135 }}
136 >
137 <div
138 ref={ref}
139 onKeyDownCapture={handleKeyDown}
140 className={classNames('relative', className)}
141 role='region'
142 aria-roledescription='carousel'
143 {...props}
144 >
145 {children}
146 </div>
147 </CarouselContext.Provider>
148 );
149 }
150);
151Carousel.displayName = 'Carousel';
152
153const CarouselContent = React.forwardRef<
154 HTMLDivElement,
155 React.HTMLAttributes<HTMLDivElement>
156>(({ className, ...props }, ref) => {
157 const { carouselRef, orientation } = useCarousel();
158
159 return (
160 <div ref={carouselRef} className='overflow-hidden'>
161 <div
162 ref={ref}
163 className={classNames(
164 'flex',
165 orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
166 className
167 )}
168 {...props}
169 />
170 </div>
171 );
172});
173CarouselContent.displayName = 'CarouselContent';
174
175const CarouselItem = React.forwardRef<
176 HTMLDivElement,
177 React.HTMLAttributes<HTMLDivElement>
178>(({ className, ...props }, ref) => {
179 const { orientation } = useCarousel();
180
181 return (
182 <div
183 ref={ref}
184 role='group'
185 aria-roledescription='slide'
186 className={classNames(
187 'min-w-0 shrink-0 grow-0 basis-full',
188 orientation === 'horizontal' ? 'pl-4' : 'pt-4',
189 className
190 )}
191 {...props}
192 />
193 );
194});
195CarouselItem.displayName = 'CarouselItem';
196
197const CarouselPrevious = React.forwardRef<
198 HTMLButtonElement,
199 React.ComponentProps<typeof Button>
200>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
201 const { orientation, scrollPrev, canScrollPrev } = useCarousel();
202
203 return (
204 <Button
205 ref={ref}
206 variant={variant}
207 size={size}
208 className={classNames(
209 'absolute h-8 w-8 rounded-full border-none bg-transparent hover:border hover:bg-neutral-200 md:h-12 md:w-12 md:p-3',
210 orientation === 'horizontal'
211 ? '-bottom-14 left-4 md:-left-16 md:top-1/2 md:-translate-y-1/2'
212 : '-top-12 left-1/2 -translate-x-1/2 rotate-90',
213 className
214 )}
215 disabled={!canScrollPrev}
216 onClick={scrollPrev}
217 {...props}
218 >
219 <ChevronLeftIcon className='relative h-6 w-6 md:right-px md:h-12 md:w-12' />
220 <span className='sr-only'>Previous slide</span>
221 </Button>
222 );
223});
224CarouselPrevious.displayName = 'CarouselPrevious';
225
226const CarouselNext = React.forwardRef<
227 HTMLButtonElement,
228 React.ComponentProps<typeof Button>
229>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
230 const { orientation, scrollNext, canScrollNext } = useCarousel();
231
232 return (
233 <Button
234 ref={ref}
235 variant={variant}
236 size={size}
237 className={classNames(
238 'absolute h-8 w-8 rounded-full border-none bg-transparent hover:border hover:bg-neutral-200 md:h-12 md:w-12 md:p-3',
239 orientation === 'horizontal'
240 ? '-bottom-14 right-4 md:-right-16 md:top-1/2 md:-translate-y-1/2'
241 : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
242 className
243 )}
244 disabled={!canScrollNext}
245 onClick={scrollNext}
246 {...props}
247 >
248 <ChevronRightIcon className='relative left-px h-6 w-6 md:h-10 md:w-10' />
249 <span className='sr-only'>Next slide</span>
250 </Button>
251 );
252});
253CarouselNext.displayName = 'CarouselNext';
254
255export {
256 type CarouselApi,
257 Carousel,
258 CarouselContent,
259 CarouselItem,
260 CarouselPrevious,
261 CarouselNext,
262};
263