Carousel

Visually highlights selected items by sliding a background into view when hovered over or clicked. This smooth transition helps users focus on the active item, making it ideal for interactive lists, menus, or navigations where clear selection feedback is important.

Installation

npm install embla-carousel embla-carousel-class-names embla-carousel-react embla-carousel-autoplay framer-motion
carousel.tsx
// @ts-nocheck
'use client';
import React, {
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
} from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import Autoplay from 'embla-carousel-autoplay';
import {
EmblaCarouselType,
EmblaEventType,
EmblaOptionsType,
} from 'embla-carousel';
import useEmblaCarousel from 'embla-carousel-react';
import ClassNames from 'embla-carousel-class-names';
import { cn } from '@/lib/utils';
type UseDotButtonType = {
selectedIndex: number;
scrollSnaps: number[];
onDotButtonClick: (index: number) => void;
};
interface CarouselProps {
children: React.ReactNode;
options: EmblaOptionsType;
className?: string;
activeSlider?: boolean;
isAutoPlay?: boolean;
isScale?: boolean;
}
interface ThumbnailSlide {
src: string;
alt: string;
}
interface CarouselContextType {
prevBtnDisabled: boolean;
nextBtnDisabled: boolean;
onPrevButtonClick: () => void;
onNextButtonClick: () => void;
selectedIndex: any;
scrollSnaps: any;
onDotButtonClick: any;
scrollProgress: any;
selectedSnap: any;
snapCount: any;
isScale: boolean;
slidesrArr: ThumbnailSlide[];
setSlidesArr: any;
emblaThumbsRef: any;
onThumbClick: any;
carouselId: string;
}
const CarouselContext = createContext<CarouselContextType | undefined>(
undefined
);
const TWEEN_FACTOR_BASE = 0.52;
const numberWithinRange = (number: number, min: number, max: number): number =>
Math.min(Math.max(number, min), max);
export const useCarouselContext = () => {
const context = useContext(CarouselContext);
if (!context) {
throw new Error(
'useCarouselContext must be used within a CarouselProvider'
);
}
return context;
};
const Carousel: React.FC<CarouselProps> = ({
children,
options,
className,
activeSlider,
isScale = false,
isAutoPlay = false,
}) => {
const carouselId = useId();
const [slidesrArr, setSlidesArr] = useState<Element[]>([]);
const plugins = [];
if (activeSlider) {
plugins.push(ClassNames());
}
if (isAutoPlay) {
plugins.push(
Autoplay({
playOnInit: true,
delay: 3000,
stopOnMouseEnter: true,
jump: false,
stopOnInteraction: false,
})
);
}
const [emblaRef, emblaApi] = useEmblaCarousel(options, plugins);
const [selectedThumbIndex, setSelectedThumbIndex] = useState(0);
const [emblaThumbsRef, emblaThumbsApi] = useEmblaCarousel({
containScroll: 'keepSnaps',
dragFree: true,
});
const onThumbClick = useCallback(
(index: number) => {
if (!emblaApi || !emblaThumbsApi) return;
emblaApi.scrollTo(index);
},
[emblaApi, emblaThumbsApi]
);
const onSelect = useCallback(() => {
if (!emblaApi || !emblaThumbsApi) return;
setSelectedThumbIndex(emblaApi.selectedScrollSnap()); // Use setSelectedThumbIndex here
emblaThumbsApi.scrollTo(emblaApi.selectedScrollSnap());
}, [emblaApi, emblaThumbsApi, setSelectedThumbIndex]);
useEffect(() => {
if (!emblaApi) return;
onSelect();
emblaApi.on('select', onSelect);
emblaApi.on('reInit', onSelect);
}, [emblaApi, onSelect]);
const { selectedIndex, scrollSnaps, onDotButtonClick } =
useDotButton(emblaApi);
const [scrollProgress, setScrollProgress] = useState(0);
const {
prevBtnDisabled,
nextBtnDisabled,
onPrevButtonClick,
onNextButtonClick,
} = usePrevNextButtons(emblaApi);
const onScroll = useCallback((emblaApi: EmblaCarouselType) => {
const progress = Math.max(0, Math.min(1, emblaApi.scrollProgress()));
setScrollProgress(progress * 100);
}, []);
useEffect(() => {
if (!emblaApi) return;
onScroll(emblaApi);
emblaApi.on('reInit', onScroll);
emblaApi.on('scroll', onScroll);
}, [emblaApi, onScroll]);
const { selectedSnap, snapCount } = useSelectedSnapDisplay(emblaApi);
// for scale animation
const tweenFactor = useRef(0);
const tweenNodes = useRef<HTMLElement[]>([]);
const setTweenNodes = useCallback(
(emblaApi: EmblaCarouselType): void => {
if (!isScale) return;
tweenNodes.current = emblaApi.slideNodes().map((slideNode, index) => {
const node = slideNode.querySelector('.slider_content') as HTMLElement;
if (!node) {
console.warn(`No .slider_content found for slide ${index}`);
}
return node;
});
},
[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];
// Add null check here
if (tweenNode) {
tweenNode.style.transform = `scale(${scale})`;
}
});
});
},
[isScale]
);
useEffect(() => {
if (!emblaApi) return;
if (isScale) {
setTweenNodes(emblaApi);
setTweenFactor(emblaApi);
tweenScale(emblaApi);
emblaApi
.on('reInit', setTweenNodes)
.on('reInit', setTweenFactor)
.on('reInit', tweenScale)
.on('scroll', tweenScale);
}
}, [emblaApi, tweenScale, isScale, setTweenNodes, setTweenFactor]);
return (
<CarouselContext.Provider
value={{
prevBtnDisabled,
nextBtnDisabled,
onPrevButtonClick,
onNextButtonClick,
selectedIndex,
scrollSnaps,
setSlidesArr,
onDotButtonClick,
scrollProgress,
selectedSnap,
snapCount,
carouselId,
isScale,
emblaThumbsRef,
onThumbClick,
slidesrArr,
}}
>
<div
className={cn(className, 'overflow-hidden rounded-md ')}
ref={emblaRef}
>
{children}
</div>
</CarouselContext.Provider>
);
};
interface SliderProps {
children: React.ReactNode;
thumnailSrc?: string;
className?: string;
}
export const SliderContainer = ({
className,
children,
}: {
className?: string;
children: ReactNode;
}) => {
return (
<div
className={cn('flex', className)}
style={{ touchAction: 'pan-y pinch-zoom' }}
>
{children}
</div>
);
};
export const Slider: React.FC<SliderProps> = ({
children,
className,
thumnailSrc,
}) => {
const { isScale, setSlidesArr } = useCarouselContext();
// console.log(thumnailSrc)
const addImgToSlider = useCallback(() => {
setSlidesArr((prev: any) => {
// Prevent adding duplicate images
return [...prev, thumnailSrc];
});
}, [setSlidesArr, thumnailSrc]);
useEffect(() => {
addImgToSlider();
}, [addImgToSlider]);
return (
<div className={cn('min-w-0 flex-grow-0 flex-shrink-0', className)}>
{isScale ? (
<>
<div className='slider_content'>{children}</div>
</>
) : (
<>{children}</>
)}
</div>
);
};
export const SliderPrevButton = ({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) => {
const { onPrevButtonClick, prevBtnDisabled }: any = useCarouselContext();
return (
<button
className={cn('', className)}
type='button'
onClick={onPrevButtonClick}
disabled={prevBtnDisabled}
>
{children}
</button>
);
};
export const SliderNextButton = ({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) => {
const { onNextButtonClick, nextBtnDisabled }: any = useCarouselContext();
return (
<>
<button
className={cn('', className)}
type='button'
onClick={onNextButtonClick}
disabled={nextBtnDisabled}
>
{children}
</button>
</>
);
};
export const SliderProgress = ({ className }: { className?: string }) => {
const { scrollProgress }: any = useCarouselContext();
return (
<div
className={cn(
' bg-gray-500 relative rounded-md h-2 justify-end items-center w-96 max-w-[90%] overflow-hidden',
className
)}
>
<div
className='dark:bg-white bg-black absolute w-full top-0 -left-[100%] bottom-0'
style={{ transform: `translate3d(${scrollProgress}%,0px,0px)` }}
/>
</div>
);
};
export const SliderSnapDisplay = ({ className }: { className?: string }) => {
const { selectedSnap, snapCount } = useCarouselContext();
const prevSnapRef = useRef(selectedSnap);
const [direction, setDirection] = useState<number>(0);
useEffect(() => {
setDirection(selectedSnap > prevSnapRef.current ? 1 : -1);
prevSnapRef.current = selectedSnap;
}, [selectedSnap]);
return (
<div
className={cn(
'mix-blend-difference overflow-hidden flex gap-1 items-center',
className
)}
>
<motion.div
key={selectedSnap}
custom={direction}
initial={(d: number) => ({ y: d * 20, opacity: 0 })}
animate={{ y: 0, opacity: 1 }}
exit={(d: number) => ({ y: d * -20, opacity: 0 })}
>
{selectedSnap + 1}
</motion.div>
<span>/ {snapCount}</span>
</div>
);
};
export const SliderDotButton = ({
className,
activeclass,
}: {
className?: string;
activeclass?: string;
}) => {
const { selectedIndex, scrollSnaps, onDotButtonClick, carouselId }: any =
useCarouselContext();
return (
<div className={cn('flex', className)}>
<div className='flex gap-2'>
{scrollSnaps.map((_: any, index: React.Key | null | undefined) => (
<button
type='button'
key={index}
onClick={() => onDotButtonClick(index)}
className={`relative inline-flex p-0 m-0 w-10 h-2 `}
>
<div className=' bg-gray-500/40 h-2 rounded-full w-10 '></div>
{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',
activeclass
)}
/>
</AnimatePresence>
)}
</button>
))}
</div>
</div>
);
};
export const useDotButton = (
emblaApi: EmblaCarouselType | undefined
): UseDotButtonType => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
const onDotButtonClick = useCallback(
(index: number) => {
if (!emblaApi) return;
emblaApi.scrollTo(index);
},
[emblaApi]
);
const onInit = useCallback((emblaApi: EmblaCarouselType) => {
setScrollSnaps(emblaApi.scrollSnapList());
}, []);
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
setSelectedIndex(emblaApi.selectedScrollSnap());
}, []);
useEffect(() => {
if (!emblaApi) return;
onInit(emblaApi);
onSelect(emblaApi);
emblaApi.on('reInit', onInit);
emblaApi.on('reInit', onSelect);
emblaApi.on('select', onSelect);
}, [emblaApi, onInit, onSelect]);
return {
selectedIndex,
scrollSnaps,
onDotButtonClick,
};
};
type UsePrevNextButtonsType = {
prevBtnDisabled: boolean;
nextBtnDisabled: boolean;
onPrevButtonClick: () => void;
onNextButtonClick: () => void;
};
export const usePrevNextButtons = (
emblaApi: EmblaCarouselType | undefined
): UsePrevNextButtonsType => {
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true);
const [nextBtnDisabled, setNextBtnDisabled] = useState(true);
const onPrevButtonClick = useCallback(() => {
if (!emblaApi) return;
emblaApi.scrollPrev();
}, [emblaApi]);
const onNextButtonClick = useCallback(() => {
if (!emblaApi) return;
emblaApi.scrollNext();
}, [emblaApi]);
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
setPrevBtnDisabled(!emblaApi.canScrollPrev());
setNextBtnDisabled(!emblaApi.canScrollNext());
}, []);
useEffect(() => {
if (!emblaApi) return;
onSelect(emblaApi);
emblaApi.on('reInit', onSelect);
emblaApi.on('select', onSelect);
}, [emblaApi, onSelect]);
return {
prevBtnDisabled,
nextBtnDisabled,
onPrevButtonClick,
onNextButtonClick,
};
};
type UseSelectedSnapDisplayType = {
selectedSnap: number;
snapCount: number;
};
export const useSelectedSnapDisplay = (
emblaApi: EmblaCarouselType | undefined
): UseSelectedSnapDisplayType => {
const [selectedSnap, setSelectedSnap] = useState(0);
const [snapCount, setSnapCount] = useState(0);
const updateScrollSnapState = useCallback((emblaApi: EmblaCarouselType) => {
setSnapCount(emblaApi.scrollSnapList().length);
setSelectedSnap(emblaApi.selectedScrollSnap());
}, []);
useEffect(() => {
if (!emblaApi) return;
updateScrollSnapState(emblaApi);
emblaApi.on('select', updateScrollSnapState);
emblaApi.on('reInit', updateScrollSnapState);
}, [emblaApi, updateScrollSnapState]);
return {
selectedSnap,
snapCount,
};
};
export const ThumsSlider: React.FC = () => {
const { emblaThumbsRef, slidesrArr, selectedIndex, onThumbClick } =
useCarouselContext();
// console.log(slidesrArr);
return (
<div className='overflow-hidden mt-2' ref={emblaThumbsRef}>
<div className='flex flex-row gap-2'>
{slidesrArr.map((slide, index) => (
<div
key={`thumb-${index}`}
className={`min-w-0 w-full xl:h-24 aspect-auto border-2 rounded-md ${
index === selectedIndex
? 'opacity-100'
: 'border-transparent opacity-30'
}`}
style={{ flex: '0 0 15%' }}
onClick={() => onThumbClick(index)}
>
<motion.img
src={slide}
className='w-full h-full object-cover rounded-sm'
width={400}
height={400}
alt={slide.alt || `Thumbnail ${index + 1}`}
/>
</div>
))}
</div>
</div>
);
};
export default Carousel;

Usage

1
2
import { EmblaOptionsType } from 'embla-carousel';
3
import Carousel, {
4
Slider,
5
SliderContainer,
6
SliderDotButton,
7
SliderNextButton,
8
SliderPrevButton,
9
} from './carousel';
10
11
function index() {
12
const OPTIONS: EmblaOptionsType = { loop: false };
13
return (
14
<>
15
<Carousel options={OPTIONS}>
16
<SliderContainer>
17
<Slider></Slider>
18
<Slider></Slider>
19
<Slider></Slider>
20
</SliderContainer>
21
22
<SliderPrevButton>
23
<ChevronLeft />
24
</SliderPrevButton>
25
26
<SliderNextButton>
27
<ChevronRight />
28
</SliderNextButton>
29
30
<SliderDotButton />
31
32
<SliderSnapDisplay />
33
</Carousel>
34
</>
35
);
36
}

Data

export const imgPreview = {
img1: 'https://images.unsplash.com/photo-1709949908058-a08659bfa922?q=80&w=1200&auto=format',
img2: 'https://images.unsplash.com/photo-1548192746-dd526f154ed9?q=80&w=1200&auto=format',
img3: 'https://images.unsplash.com/photo-1693581176773-a5f2362209e6?q=80&w=1200&auto=format',
img4: 'https://images.unsplash.com/photo-1584043204475-8cc101d6c77a?q=80&w=1200&auto=format',
img5: 'https://images.unsplash.com/photo-1709949908058-a08659bfa922?q=80&w=1200&auto=format',
img6: 'https://images.unsplash.com/photo-1518599904199-0ca897819ddb?q=80&w=1200&auto=format',
img7: 'https://images.unsplash.com/photo-1706049379414-437ec3a54e93?q=80&w=1200&auto=format',
img8: 'https://images.unsplash.com/photo-1709949908219-fd9046282019?q=80&w=1200&auto=format',
img9: 'https://images.unsplash.com/photo-1508873881324-c92a3fc536ba?q=80&w=1200&auto=format',
img10:
'https://images.unsplash.com/photo-1462989856370-729a9c1e2c91?q=80&w=1200&auto=format',
img11:
'https://images.unsplash.com/photo-1475727946784-2890c8fdb9c8?q=80&w=1200&auto=format',
img12:
'https://images.unsplash.com/photo-1546942113-a6c43b63104a?q=80&w=1200&auto=format',
img13:
'https://images.unsplash.com/photo-1462989856370-729a9c1e2c91?q=80&w=1200&auto=format',
img14:
'https://images.unsplash.com/photo-1462989856370-729a9c1e2c91?q=80&w=1200&auto=format',
img15:
'https://images.unsplash.com/photo-1532423622396-10a3f979251a?q=80&w=1200&auto=format',
img16:
'https://images.unsplash.com/photo-1490750967868-88aa4486c946?q=80&w=1200&auto=format',
img17:
'https://images.unsplash.com/photo-1496861083958-175bb1bd5702?q=80&w=1200&auto=format',
img18:
'https://images.unsplash.com/photo-1469212044023-0e55b4b9745a?q=80&w=1200&auto=format',
};

Props

PropTypeDefaultDescription
defaultValuestringstring[]undefined
multiplebooleanfalseWhether the accordion allows multiple items to be active at the same time
childrenReactNode[]undefinedThe accordion items, including their headers and panels
classNamestring''Optional CSS class for styling the accordion wrapper

Example

Align Slider

Get An Beautiful Carousel for your layout.

Classname Slider

Number Slider

1
/ 0

Scale Slider

Thumnail Slider

image
image
image
image
image
image
image
image
image
image

Autostart Slider

Autoplay