This document outlines the steps to create Galleria
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// 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
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