Search

Ctrl + K

Galleria

This document outlines the steps to create Galleria 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 Tailwind configs
1// tailwind.config.js
2module.exports = {
3  ...
4  theme: {
5    ...
6    extend: {
7      ...
8      keyframes: {
9        splash: {
10          from: { opacity: "1" },
11          to: { opacity: "0" },
12        },
13      },
14      animation: {
15        splash: "splash 0.15s ease-in-out",
16      },
17    },
18  },
19  plugins: [
20    require("tailwindcss-animate"),
21    require("tailwind-scrollbar-hide"),
22  ]
23};
24
Step 2: Create Galleria component
1// galleria.component.tsx
2import clsx from "clsx";
3import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
4import { useEffect, useRef, useState } from "react";
5
6export interface GalleriaProps {
7  images: Array<{ photoUrl: string; thumbUrl: string }>;
8  animation?: boolean;
9  className?: string;
10}
11
12const Galleria: React.FC<GalleriaProps> = ({
13  images,
14  animation = true,
15  className,
16}) => {
17  const containerRef = useRef<HTMLDivElement>(null);
18  const currentIndexRef = useRef<number>(0);
19
20  const [currentIndex, setCurrentIndex] = useState<number>(0);
21  const [visibleImages, setVisibleImages] = useState<number>(5);
22  const [changedCount, setChangedCount] = useState<number>(0);
23
24  const getVisibleImagesCount = (containerWidth: number) => {
25    const imageWidth = 88;
26    return Math.floor(containerWidth / imageWidth);
27  };
28
29  useEffect(() => {
30    const updateScrollPosition = (
31      containerWidth: number,
32      visibleImages: number,
33    ) => {
34      const container = containerRef.current!;
35      const itemWidth = containerWidth / visibleImages;
36      const scrollLeft = itemWidth * currentIndexRef.current;
37      container.scrollTo({
38        left: scrollLeft,
39        behavior: "instant",
40      });
41    };
42
43    const observer = new ResizeObserver((entries) => {
44      entries.forEach((entry) => {
45        const containerWidth = entry.contentRect.width;
46        const visibleImages = getVisibleImagesCount(containerWidth);
47        setVisibleImages(visibleImages);
48        updateScrollPosition(containerWidth, visibleImages);
49      });
50    });
51
52    observer.observe(containerRef.current!);
53
54    return () => {
55      observer.disconnect();
56    };
57  }, []);
58
59  useEffect(() => {
60    const containerWidth = containerRef.current!.clientWidth;
61    const visibleImages = getVisibleImagesCount(containerWidth);
62    setVisibleImages(visibleImages);
63  }, []);
64
65  useEffect(() => {
66    const container = containerRef.current!;
67    const itemWidth = container.clientWidth / visibleImages;
68    const scrollLeft = itemWidth * currentIndexRef.current;
69    container.scrollTo({
70      left: scrollLeft,
71      behavior: "instant",
72    });
73  }, [visibleImages]);
74
75  const changeCurrentIndex = (index: number) => {
76    setChangedCount((prev) => prev + 1);
77    setCurrentIndex(index);
78    currentIndexRef.current = index;
79  };
80
81  const handleNext = (step: number) => {
82    if (currentIndex >= images.length - step) return;
83
84    const newIndex = currentIndex + step;
85
86    const container = containerRef.current!;
87
88    const scrollLeft =
89      container.scrollLeft + (step * container.clientWidth) / visibleImages;
90
91    container.scrollTo({
92      left: scrollLeft,
93      behavior: "smooth",
94    });
95
96    changeCurrentIndex(newIndex);
97  };
98
99  const handlePrev = (step: number) => {
100    if (currentIndex < step) return;
101
102    const newIndex = currentIndex - step;
103
104    const container = containerRef.current!;
105
106    const scrollLeft =
107      container.scrollLeft - (step * container.clientWidth) / visibleImages;
108
109    container.scrollTo({
110      left: scrollLeft,
111      behavior: "smooth",
112    });
113
114    changeCurrentIndex(newIndex);
115  };
116
117  const handleClick = (index: number) => {
118    if (index === currentIndex) return;
119    if (index < currentIndex) handlePrev(currentIndex - index);
120    else handleNext(index - currentIndex);
121  };
122
123  return (
124    <div
125      style={{ aspectRatio: 1 }}
126      className={clsx(
127        "flex w-full min-w-[32rem] flex-col bg-neutral-900",
128        className,
129      )}
130    >
131      <div className="relative flex-auto">
132        {images.map((item, index) => (
133          <img
134            key={index}
135            src={item.photoUrl}
136            alt=""
137            className={cn("h-full w-full object-cover", {
138              hidden: currentIndex !== index,
139            })}
140          />
141        ))}
142        {animation && changedCount > 0 && (
143          <div
144            key={changedCount}
145            className="animate-splash absolute inset-0 bg-white fill-mode-forwards"
146          />
147        )}
148      </div>
149      <div className="flex h-[5rem] items-center">
150        <button
151          className="mx-2 bg-transparent text-white disabled:text-neutral-500"
152          disabled={currentIndex === 0}
153          onClick={() => handlePrev(1)}
154        >
155          <ChevronLeftIcon className="h-6 w-6" />
156        </button>
157        <div
158          ref={containerRef}
159          className="scrollbar-hide flex flex-auto flex-nowrap overflow-auto"
160        >
161          {images.map((image, index) => (
162            <div
163              key={index}
164              style={{ width: `${100 / visibleImages}%` }}
165              className="h-[3.5rem] flex-shrink-0 px-2"
166            >
167              <div
168                className="relative h-full w-full cursor-pointer"
169                onClick={() => handleClick(index)}
170              >
171                <img
172                  src={image.thumbUrl}
173                  alt=""
174                  className="h-full w-full cursor-pointer select-none object-cover"
175                />
176                {index !== currentIndex && (
177                  <div className="absolute inset-0 bg-black bg-opacity-50" />
178                )}
179              </div>
180            </div>
181          ))}
182        </div>
183        <button
184          className="mx-2 bg-transparent text-white disabled:text-neutral-500"
185          disabled={currentIndex === images.length - 1}
186          onClick={() => handleNext(1)}
187        >
188          <ChevronRightIcon className="h-6 w-6" />
189        </button>
190      </div>
191    </div>
192  );
193};
194
195export default Galleria;
196