Search

Ctrl + K

Carousel

This document outlines the steps to create Carousel component styled withTailwind CSS and using some npm dependency libraries.

Prerequisites

  • 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.
Step 1: Create ArrowButton component
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
Step 2: Create Paginator component
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
Step 3: Create Carousel component
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