This document outlines the steps to create Carousel
component styled withTailwind CSS and using some npm dependency libraries.
Node.js
and npm
installed on your machine.Tailwind CSS
installed in your project.CVA(class-variance-authority)
is a utility for managing CSS class names based on various conditions.clsx
is a tiny utility for constructing className strings conditionally.1// arrow-button.tsx
2import clsx from "clsx";
3import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
4import { forwardRef } from "react";
5
6interface Props {
7 type: "prev" | "next";
8 disabled: boolean;
9 onClick: () => void;
10}
11
12const ArrowButton = forwardRef<HTMLButtonElement, Props>(
13 ({ type, disabled, onClick }, ref) => {
14 return (
15 <button
16 ref={ref}
17 onClick={onClick}
18 className={clsx(
19 "absolute top-1/2 z-[1] h-12 w-12 -translate-y-1/2 rounded-full shadow-md",
20 "bg-white text-neutral-900 opacity-80 disabled:opacity-50",
21 "flex items-center justify-center",
22 type === "prev" ? "left-6" : "right-6",
23 )}
24 disabled={disabled}
25 >
26 {type === "prev" ? (
27 <ChevronLeftIcon width={24} height={24} />
28 ) : (
29 <ChevronRightIcon width={24} height={24} />
30 )}
31 </button>
32 );
33 },
34);
35
36ArrowButton.displayName = "ArrowButton";
37
38export default ArrowButton;
39
1// paginator.tsx
2import clsx from "clsx";
3
4interface Props {
5 current: number;
6 total: number;
7}
8
9const Paginator: React.FC<Props> = ({ current, total }) => {
10 const currentPage = current >= total - 1 ? total - 1 : current;
11
12 return (
13 <div className="absolute inset-x-3 bottom-4 z-[1] flex items-center justify-center gap-2">
14 {Array.from({ length: total }).map((_, index) => (
15 <div
16 key={index}
17 className={clsx(
18 "h-2 w-2 rounded-full shadow-md",
19 index === currentPage ? "bg-neutral-200" : "bg-neutral-400",
20 )}
21 />
22 ))}
23 </div>
24 );
25};
26
27export default Paginator;
28
1// carousel.component.tsx
2import clsx from "clsx";
3import { Children, useEffect, useLayoutEffect, useRef, useState } from "react";
4import ArrowButton from "./arrow-button";
5import Paginator from "./paginator";
6
7export interface CarouselProps {
8 children: React.ReactNode;
9 show?: number;
10 infiniteLoop?: boolean;
11 showIndicator?: boolean;
12 autoplay?: number;
13 disabledAutoplay?: boolean;
14 className?: string;
15 itemClassName?: string;
16}
17
18const Carousel: React.FC<CarouselProps> = ({
19 children,
20 show = 1,
21 infiniteLoop = false,
22 showIndicator = true,
23 autoplay = 3000,
24 disabledAutoplay,
25 className,
26 itemClassName,
27}) => {
28 const [currentIndex, setCurrentIndex] = useState<number>(
29 infiniteLoop ? show : 0,
30 );
31 const [length, setLength] = useState<number>(
32 infiniteLoop
33 ? Children.count(children) + show * 2
34 : Children.count(children),
35 );
36 const [totalPages, setTotalPages] = useState(
37 Children.count(children) > show ? Children.count(children) - show + 1 : 1,
38 );
39 const [isRepeating, setIsRepeating] = useState<boolean>(
40 infiniteLoop && Children.count(children) > show,
41 );
42
43 const isForeground = useRef<boolean>(false);
44 const wrapperRef = useRef<HTMLDivElement>(null);
45 const nextButtonRef = useRef<HTMLButtonElement>(null);
46
47 const handleChangeIndex = (index: number, animation: boolean) => {
48 wrapperRef.current!.style.transition = animation
49 ? "transform 0.15s linear"
50 : "none";
51 wrapperRef.current!.style.transform = `translateX(-${(index * 100) / show}%)`;
52 setCurrentIndex(+index);
53 };
54
55 useEffect(() => {
56 const handleVisibilityChange = () => {
57 isForeground.current = document.visibilityState === "hidden";
58 };
59
60 handleVisibilityChange();
61
62 document.addEventListener("visibilitychange", handleVisibilityChange);
63
64 return () => {
65 document.removeEventListener("visibilitychange", handleVisibilityChange);
66 };
67 }, []);
68
69 useEffect(() => {
70 if (autoplay <= 0 || !infiniteLoop || disabledAutoplay) return;
71
72 const interval = setInterval(() => {
73 if (isForeground.current) return;
74 nextButtonRef.current?.click();
75 }, autoplay);
76
77 return () => clearInterval(interval);
78 }, [autoplay, infiniteLoop, disabledAutoplay]);
79
80 useLayoutEffect(() => {
81 const childrenCount = Children.count(children);
82 setLength(infiniteLoop ? childrenCount + show * 2 : childrenCount);
83 setIsRepeating(infiniteLoop && childrenCount > show);
84 handleChangeIndex(infiniteLoop ? show : 0, false);
85 setTotalPages(childrenCount > show ? childrenCount - show + 1 : 1);
86 // eslint-disable-next-line react-hooks/exhaustive-deps
87 }, [children, infiniteLoop, show]);
88
89 const next = () => {
90 if (isRepeating || currentIndex < length - show) {
91 handleChangeIndex(currentIndex + 1, true);
92 }
93 };
94
95 const prev = () => {
96 if (isRepeating || currentIndex > 0) {
97 handleChangeIndex(currentIndex - 1, true);
98 }
99 };
100
101 const handleTransitionEnd = () => {
102 if (!isRepeating) return;
103
104 if (currentIndex === 0) {
105 handleChangeIndex(length - show * 2, false);
106 } else if (currentIndex === length - show) {
107 handleChangeIndex(show, false);
108 }
109 };
110
111 const getList = () => {
112 const childList = Children.toArray(children);
113 const output = [];
114
115 if (isRepeating) {
116 for (let index = 0; index < show; index++) {
117 output.unshift(childList[childList.length - 1 - index]);
118 }
119 }
120
121 for (let index = 0; index < childList.length; index++) {
122 output.push(childList[index]);
123 }
124
125 if (isRepeating) {
126 for (let index = 0; index < show; index++) {
127 output.push(childList[index]);
128 }
129 }
130
131 return output;
132 };
133
134 return (
135 <div className={clsx("flex w-full flex-col", className)}>
136 <div className="relative flex w-full">
137 <ArrowButton
138 type="prev"
139 disabled={!isRepeating && currentIndex <= 0}
140 onClick={prev}
141 />
142 <ArrowButton
143 ref={nextButtonRef}
144 type="next"
145 disabled={!isRepeating && currentIndex >= length - show}
146 onClick={next}
147 />
148 {showIndicator && (
149 <Paginator
150 current={infiniteLoop ? currentIndex - show : currentIndex}
151 total={totalPages}
152 />
153 )}
154 <div className="h-full w-full overflow-hidden">
155 <div
156 ref={wrapperRef}
157 style={{ transform: `translateX(-${infiniteLoop ? 100 : 0}%)` }}
158 className="scrollbar-hide flex"
159 onTransitionEnd={handleTransitionEnd}
160 >
161 {getList().map((child, index) => (
162 <div
163 key={index}
164 style={{ width: `calc(100% / ${show})` }}
165 className={clsx(
166 "shrink-0 grow",
167 { "p-2": show > 1 },
168 itemClassName,
169 )}
170 >
171 {child}
172 </div>
173 ))}
174 </div>
175 </div>
176 </div>
177 </div>
178 );
179};
180
181export default Carousel;
182