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