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