Framer Normal Carousel
Framer Normal Carousel é um componente de media para React e Tailwind CSS, da biblioteca UI Layouts, com licença MIT. Copia e cola no teu projeto.
UI Layouts
MIT
media
O que é
Framer Normal Carousel é um componente de media para React e Tailwind CSS, da biblioteca UI Layouts, com licença MIT. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Cinco cópias grátis por mês. Sem cartão.
Precisa de
npm i motion
Licença
Este componente vem de UI Layouts e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.
Framer Normal Carousel
'use client';
import { animate, motion, useMotionValue } from 'motion/react';
import React, { useEffect, useRef, useState } from 'react';
export const items = [
{
id: 3,
url: 'https://images.unsplash.com/photo-1761882725885-d3d8bd2032d1?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=687',
title: 'AUSIZE MAM',
},
{
id: 2,
url: 'https://images.unsplash.com/photo-1661980494567-40a5e01b699b?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=685',
title: 'BOXIEN BAY',
},
{
id: 4,
url: 'https://images.unsplash.com/photo-1761775915848-467e41c1c4db?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=689',
title: 'RECLKTIKA',
},
{
id: 1,
url: 'https://images.unsplash.com/photo-1761882835101-02ab45ac0726?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=690',
title: 'MAXX PHAM',
},
{
id: 5,
url: 'https://images.unsplash.com/photo-1761078980679-e89e25fe279b?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=687',
title: 'SONYPOO',
},
{
id: 6,
url: 'https://images.unsplash.com/photo-1760389005000-bf02bf24f463?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=1123',
title: 'DONM FLY',
},
{
id: 7,
url: 'https://images.unsplash.com/photo-1761165307495-56bd564d322f?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=663',
title: 'Snowy Mountain Highway',
},
{
id: 8,
url: 'https://images.unsplash.com/photo-1756299792672-157811bf1005?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=1074',
title: 'FOGGY FOLS',
},
{
id: 9,
url: 'https://images.unsplash.com/photo-1572851899646-a1f69c664e1e?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=1170',
title: 'DIM DARKO',
},
{
id: 10,
url: 'https://images.unsplash.com/photo-1759247178379-0e8eba83a4a6?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=687',
title: 'BEALIVE',
},
{
id: 11,
url: 'https://images.unsplash.com/photo-1754968230523-052635c98f99?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=736',
title: 'DOMEDOM ROME',
},
{
id: 12,
url: 'https://images.unsplash.com/photo-1643037508102-46fb319979c5?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=764',
title: 'IKEIMON POVE',
},
];
export default function FramerCarousel() {
const [index, setIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const x = useMotionValue(0);
useEffect(() => {
if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth || 1;
const targetX = -index * containerWidth;
animate(x, targetX, {
type: 'spring',
stiffness: 300,
damping: 30,
});
}
}, [index, x]);
return (
<div className='w-full lg:p-10 sm:p-4 p-2'>
<div className='flex flex-col gap-3'>
<div className='relative overflow-hidden rounded-lg' ref={containerRef}>
<motion.div className='flex' style={{ x }}>
{items.map((item) => (
<div key={item.id} className='shrink-0 w-full h-[400px]'>
<img
src={item.url}
alt={item.title}
className='w-full h-full object-cover rounded-lg select-none pointer-events-none'
draggable={false}
/>
</div>
))}
</motion.div>
{/* Navigation Buttons */}
<motion.button
disabled={index === 0}
onClick={() => setIndex((i) => Math.max(0, i - 1))}
className={`absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full flex items-center justify-center shadow-lg transition-transform z-10
${
index === 0
? 'opacity-40 cursor-not-allowed'
: 'bg-white hover:scale-110 hover:opacity-100 opacity-70'
}`}
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M15 19l-7-7 7-7'
/>
</svg>
</motion.button>
{/* Next Button */}
<motion.button
disabled={index === items.length - 1}
onClick={() => setIndex((i) => Math.min(items.length - 1, i + 1))}
className={`absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full flex items-center justify-center shadow-lg transition-transform z-10
${
index === items.length - 1
? 'opacity-40 cursor-not-allowed'
: 'bg-white hover:scale-110 hover:opacity-100 opacity-70'
}`}
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M9 5l7 7-7 7' />
</svg>
</motion.button>
{/* Progress Indicator */}
<div className='absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2'>
{items.map((_, i) => (
<button
key={items[i]?.id ?? items[i]?.url ?? `dot-${i}`}
onClick={() => setIndex(i)}
className={`h-2 rounded-full transition-all ${
i === index ? 'w-8 bg-white' : 'w-2 bg-white/50'
}`}
/>
))}
</div>
</div>
</div>
</div>
);
}
'use client';
import { cn } from '@/lib/utils';
import type { EmblaCarouselType, EmblaEventType, EmblaOptionsType } from 'embla-carousel';
import useEmblaCarousel from 'embla-carousel-react';
import { AnimatePresence, motion } from 'motion/react';
import type React from 'react';
import {
createContext,
forwardRef,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
} from 'react';
// ============= TYPES =============
interface CarouselProps extends React.HTMLAttributes<HTMLDivElement> {
options?: EmblaOptionsType;
plugins?: Parameters<typeof useEmblaCarousel>[1];
isScale?: boolean;
}
interface CarouselContextType {
emblaApi: EmblaCarouselType | undefined;
emblaThumbsApi: EmblaCarouselType | undefined;
emblaRef: ReturnType<typeof useEmblaCarousel>[0];
emblaThumbsRef: ReturnType<typeof useEmblaCarousel>[0];
prevBtnDisabled: boolean;
nextBtnDisabled: boolean;
onPrevButtonClick: () => void;
onNextButtonClick: () => void;
selectedIndex: number;
scrollSnaps: number[];
onDotButtonClick: (index: number) => void;
scrollProgress: number;
selectedSnap: number;
snapCount: number;
isScale: boolean;
slidesArr: string[];
setSlidesArr: React.Dispatch<React.SetStateAction<string[]>>;
onThumbClick: (index: number) => void;
carouselId: string;
orientation: 'vertical' | 'horizontal';
direction: 'ltr' | 'rtl' | undefined;
handleKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
}
// ============= CONTEXT =============
const CarouselContext = createContext<CarouselContextType | undefined>(undefined);
export const useCarousel = () => {
const context = useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a Carousel component');
}
return context;
};
// ============= UTILITIES =============
const TWEEN_FACTOR_BASE = 0.52;
const numberWithinRange = (number: number, min: number, max: number): number =>
Math.min(Math.max(number, min), max);
// ============= MAIN CAROUSEL COMPONENT =============
export const Carousel = forwardRef<HTMLDivElement, CarouselProps>(
({ children, options = {}, plugins = [], className, isScale = false, dir, ...props }, ref) => {
const carouselId = useId();
const [slidesArr, setSlidesArr] = useState<string[]>([]);
const orientation = options.axis === 'y' ? 'vertical' : 'horizontal';
const direction = options.direction ?? (dir as 'ltr' | 'rtl' | undefined);
// Main carousel
const [emblaRef, emblaApi] = useEmblaCarousel(
{
...options,
axis: orientation === 'vertical' ? 'y' : 'x',
direction,
},
plugins
);
// Thumbnails carousel
const [emblaThumbsRef, emblaThumbsApi] = useEmblaCarousel({
containScroll: 'keepSnaps',
dragFree: true,
axis: orientation === 'vertical' ? 'y' : 'x',
direction,
});
// State
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true);
const [nextBtnDisabled, setNextBtnDisabled] = useState(true);
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
const [scrollProgress, setScrollProgress] = useState(0);
const [snapCount, setSnapCount] = useState(0);
// Navigation callbacks
const onPrevButtonClick = useCallback(() => {
emblaApi?.scrollPrev();
}, [emblaApi]);
const onNextButtonClick = useCallback(() => {
emblaApi?.scrollNext();
}, [emblaApi]);
const onDotButtonClick = useCallback(
(index: number) => {
emblaApi?.scrollTo(index);
},
[emblaApi]
);
const onThumbClick = useCallback(
(index: number) => {
if (!emblaApi || !emblaThumbsApi) return;
emblaApi.scrollTo(index);
},
[emblaApi, emblaThumbsApi]
);
// Keyboard navigation
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (!emblaApi) return;
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
if (orientation === 'horizontal') {
direction === 'rtl' ? onNextButtonClick() : onPrevButtonClick();
}
break;
case 'ArrowRight':
event.preventDefault();
if (orientation === 'horizontal') {
direction === 'rtl' ? onPrevButtonClick() : onNextButtonClick();
}
break;
case 'ArrowUp':
event.preventDefault();
if (orientation === 'vertical') onPrevButtonClick();
break;
case 'ArrowDown':
event.preventDefault();
if (orientation === 'vertical') onNextButtonClick();
break;
}
},
[emblaApi, orientation, direction, onPrevButtonClick, onNextButtonClick]
);
// Selection handler
const onSelect = useCallback(() => {
if (!emblaApi) return;
setSelectedIndex(emblaApi.selectedScrollSnap());
setPrevBtnDisabled(!emblaApi.canScrollPrev());
setNextBtnDisabled(!emblaApi.canScrollNext());
emblaThumbsApi?.scrollTo(emblaApi.selectedScrollSnap());
}, [emblaApi, emblaThumbsApi]);
// Scroll progress handler
const onScroll = useCallback((emblaApi: EmblaCarouselType) => {
const progress = Math.max(0, Math.min(1, emblaApi.scrollProgress()));
setScrollProgress(progress * 100);
}, []);
// Scale animation for isScale mode
const tweenFactor = useRef(0);
const tweenNodes = useRef<HTMLElement[]>([]);
const setTweenNodes = useCallback(
(emblaApi: EmblaCarouselType): void => {
if (!isScale) return;
tweenNodes.current = emblaApi
.slideNodes()
.map((slideNode) => slideNode.querySelector('.slider_content')) as HTMLElement[];
},
[isScale]
);
const setTweenFactor = useCallback(
(emblaApi: EmblaCarouselType) => {
if (!isScale) return;
tweenFactor.current = TWEEN_FACTOR_BASE * emblaApi.scrollSnapList().length;
},
[isScale]
);
const tweenScale = useCallback(
(emblaApi: EmblaCarouselType, eventName?: EmblaEventType) => {
if (!isScale) return;
const engine = emblaApi.internalEngine();
const scrollProgress = emblaApi.scrollProgress();
const slidesInView = emblaApi.slidesInView();
const isScrollEvent = eventName === 'scroll';
emblaApi.scrollSnapList().forEach((scrollSnap, snapIndex) => {
let diffToTarget = scrollSnap - scrollProgress;
const slidesInSnap = engine.slideRegistry[snapIndex];
slidesInSnap.forEach((slideIndex) => {
if (isScrollEvent && !slidesInView.includes(slideIndex)) return;
if (engine.options.loop) {
engine.slideLooper.loopPoints.forEach((loopItem) => {
const target = loopItem.target();
if (slideIndex === loopItem.index && target !== 0) {
const sign = Math.sign(target);
if (sign === -1) {
diffToTarget = scrollSnap - (1 + scrollProgress);
}
if (sign === 1) {
diffToTarget = scrollSnap + (1 - scrollProgress);
}
}
});
}
const tweenValue = 1 - Math.abs(diffToTarget * tweenFactor.current);
const scale = numberWithinRange(tweenValue, 0, 1).toString();
const tweenNode = tweenNodes.current[slideIndex];
if (tweenNode) {
tweenNode.style.transform = `scale(${scale})`;
}
});
});
},
[isScale]
);
// Effects
useEffect(() => {
if (!emblaApi) return;
setScrollSnaps(emblaApi.scrollSnapList());
setSnapCount(emblaApi.scrollSnapList().length);
onSelect();
onScroll(emblaApi);
emblaApi
.on('reInit', onSelect)
.on('select', onSelect)
.on('reInit', onScroll)
.on('scroll', onScroll);
if (isScale) {
setTweenNodes(emblaApi);
setTweenFactor(emblaApi);
tweenScale(emblaApi);
emblaApi
.on('reInit', setTweenNodes)
.on('reInit', setTweenFactor)
.on('reInit', tweenScale)
.on('scroll', tweenScale);
}
}, [emblaApi, onSelect, onScroll, isScale, setTweenNodes, setTweenFactor, tweenScale]);
return (
<CarouselContext.Provider
value={{
emblaApi,
emblaThumbsApi,
emblaRef,
emblaThumbsRef,
prevBtnDisabled,
nextBtnDisabled,
onPrevButtonClick,
onNextButtonClick,
selectedIndex,
scrollSnaps,
onDotButtonClick,
scrollProgress,
selectedSnap: selectedIndex,
snapCount,
isScale,
slidesArr,
setSlidesArr,
onThumbClick,
carouselId,
orientation,
direction,
handleKeyDown,
}}
>
<div
ref={ref}
tabIndex={0}
onKeyDownCapture={handleKeyDown}
className={cn('relative w-full focus:outline-hidden', className)}
dir={direction}
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
);
Carousel.displayName = 'Carousel';
// ============= SLIDER CONTAINER =============
export const SliderContainer = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
const { emblaRef, orientation } = useCarousel();
return (
<div ref={emblaRef} className='overflow-hidden' {...props}>
<div
ref={ref}
className={cn('flex', orientation === 'vertical' ? 'flex-col' : 'flex-row', className)}
style={{ touchAction: 'pan-y pinch-zoom' }}
>
{children}
</div>
</div>
);
}
);
SliderContainer.displayName = 'SliderContainer';
// ============= SLIDER ITEM =============
interface SliderProps extends React.HTMLAttributes<HTMLDivElement> {
thumbnailSrc?: string;
}
export const Slider = forwardRef<HTMLDivElement, SliderProps>(
({ children, className, thumbnailSrc, ...props }, ref) => {
const { isScale, setSlidesArr, orientation } = useCarousel();
useEffect(() => {
if (thumbnailSrc) {
setSlidesArr((prev) => {
if (!prev.includes(thumbnailSrc)) {
return [...prev, thumbnailSrc];
}
return prev;
});
}
}, [thumbnailSrc, setSlidesArr]);
return (
<div
ref={ref}
className={cn(
'min-w-0 shrink-0 grow-0',
// orientation === 'vertical' ? 'pb-1' : 'pr-1',
className
)}
{...props}
>
{isScale ? <div className='slider_content'>{children}</div> : children}
</div>
);
}
);
Slider.displayName = 'Slider';
// ============= NAVIGATION BUTTONS =============
export const SliderPrevButton = forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ children, className, ...props }, ref) => {
const { onPrevButtonClick, prevBtnDisabled } = useCarousel();
return (
<button
ref={ref}
type='button'
onClick={onPrevButtonClick}
disabled={prevBtnDisabled}
className={cn('', className)}
{...props}
>
{children}
</button>
);
});
SliderPrevButton.displayName = 'SliderPrevButton';
export const SliderNextButton = forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ children, className, ...props }, ref) => {
const { onNextButtonClick, nextBtnDisabled } = useCarousel();
return (
<button
ref={ref}
type='button'
onClick={onNextButtonClick}
disabled={nextBtnDisabled}
className={cn('', className)}
{...props}
>
{children}
</button>
);
});
SliderNextButton.displayName = 'SliderNextButton';
// ============= PROGRESS BAR =============
export const SliderProgress = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { scrollProgress } = useCarousel();
return (
<div
ref={ref}
className={cn(
'bg-neutral-500 relative rounded-md h-2 w-96 max-w-full overflow-hidden',
className
)}
{...props}
>
<div
className='dark:bg-white bg-black absolute w-full top-0 -left-full bottom-0 transition-transform'
style={{ transform: `translate3d(${scrollProgress}%,0px,0px)` }}
/>
</div>
);
}
);
SliderProgress.displayName = 'SliderProgress';
// ============= SNAP DISPLAY =============
export const SliderSnapDisplay = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { selectedSnap, snapCount } = useCarousel();
const prevSnapRef = useRef(selectedSnap);
const direction = selectedSnap > prevSnapRef.current ? 1 : -1;
useEffect(() => {
prevSnapRef.current = selectedSnap;
}, [selectedSnap]);
return (
<div
ref={ref}
className={cn('mix-blend-difference overflow-hidden flex gap-1 items-center', className)}
{...props}
>
<AnimatePresence mode='wait'>
<motion.div
key={selectedSnap}
custom={direction}
// @ts-expect-error
initial={(d: number) => ({ y: d * 20, opacity: 0 })}
animate={{ y: 0, opacity: 1 }}
// @ts-expect-error
exit={(d: number) => ({ y: d * -20, opacity: 0 })}
>
{selectedSnap + 1}
</motion.div>
</AnimatePresence>
<span>/ {snapCount}</span>
</div>
);
}
);
SliderSnapDisplay.displayName = 'SliderSnapDisplay';
// ============= DOT BUTTONS =============
interface SliderDotButtonProps extends React.HTMLAttributes<HTMLDivElement> {
activeClass?: string;
}
export const SliderDotButton = forwardRef<HTMLDivElement, SliderDotButtonProps>(
({ className, activeClass, ...props }, ref) => {
const { selectedIndex, scrollSnaps, orientation, onDotButtonClick, carouselId } = useCarousel();
return (
<div ref={ref} className={cn('flex gap-2', className)} {...props}>
{scrollSnaps.map((_, index) => (
<button
key={`${carouselId}-dot-${_}`}
type='button'
onClick={() => onDotButtonClick(index)}
className={cn(
'relative inline-flex p-0 m-0',
orientation === 'vertical' ? 'h-6 w-1' : 'w-6 h-1'
)}
>
<div
className={cn(
'bg-neutral-500/40 rounded-full ',
orientation === 'vertical' ? 'h-6 w-1' : 'w-6 h-1'
)}
/>
{index === selectedIndex && (
<AnimatePresence mode='wait'>
<motion.div
transition={{
layout: {
duration: 0.4,
ease: 'easeInOut',
delay: 0.04,
},
}}
layoutId={`hover-${carouselId}`}
className={cn(
'absolute z-3 w-full h-full left-0 top-0 dark:bg-white bg-black rounded-full',
orientation === 'vertical' ? 'h-6 w-1' : 'w-6 h-1',
activeClass
)}
/>
</AnimatePresence>
)}
</button>
))}
</div>
);
}
);
SliderDotButton.displayName = 'SliderDotButton';
// ============= CAROUSEL INDICATORS =============
interface CarouselIndicatorProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
index: number;
}
export const CarouselIndicator = forwardRef<HTMLButtonElement, CarouselIndicatorProps>(
({ className, index, ...props }, ref) => {
const { selectedIndex, onDotButtonClick } = useCarousel();
const isActive = selectedIndex === index;
return (
<button
ref={ref}
type='button'
onClick={() => onDotButtonClick(index)}
className={cn(
'h-1.5 w-6 rounded-full transition-colors',
isActive ? 'bg-primary' : 'bg-primary/50',
className
)}
aria-label={`Go to slide ${index + 1}`}
{...props}
>
<span className='sr-only'>Slide {index + 1}</span>
</button>
);
}
);
CarouselIndicator.displayName = 'CarouselIndicator';
// Auto-generate thumbnails from slides
export const ThumbsSlider = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
thumbsClassName?: string;
thumbsSliderClassName?: string;
}
>(({ className, thumbsClassName, thumbsSliderClassName, ...props }, ref) => {
const { slidesArr, selectedIndex, onThumbClick, orientation, emblaThumbsRef } = useCarousel();
if (slidesArr.length === 0) return null;
return (
<div ref={emblaThumbsRef} className={cn('overflow-hidden', className)} {...props}>
<div
ref={ref}
className={cn(
'flex gap-2 h-[300px]',
orientation === 'vertical' ? 'flex-col' : 'flex-row',
thumbsClassName
)}
>
{slidesArr.map((src, index) => (
<div
key={src}
onClick={() => onThumbClick(index)}
className={cn(
'shrink-0 cursor-pointer transition-opacity',
'border-2 rounded-md',
orientation === 'vertical' ? 'basis-[15%] h-20' : 'basis-[15%] h-24',
selectedIndex === index
? 'opacity-100 border-primary'
: 'opacity-30 border-transparent',
thumbsSliderClassName
)}
>
<img
src={src}
alt={`Thumbnail ${index + 1}`}
className='w-full h-full object-cover rounded-md'
/>
</div>
))}
</div>
</div>
);
});
ThumbsSlider.displayName = 'ThumbsSlider';
// Alias for backward compatibility