Command Palette

Search for a command to run...

carousel

An interactive collection of stacked headings that reveal individual content sections with a click.

Installation

Install the following dependencies:

pnpm add motion

Create a file at components/motion/carousel.tsx and paste the given code.

"use client";
import React, {
    ReactNode,
    createContext,
    useCallback,
    useContext,
    useEffect,
    useId,
    useRef,
    useState,
} from "react";
import { motion, AnimatePresence} from "motion/react";
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;
};
 
/*eslint-disable @typescript-eslint/no-explicit-any*/
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<ThumbnailSlide[]>([]);
    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);
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    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();
 
    const addImgToSlider = useCallback(() => {
        if (!thumnailSrc) return;
        setSlidesArr((prev: ThumbnailSlide[]) => [...prev, { src: thumnailSrc, alt: `Slide ${prev.length + 1}` }]);
    }, [setSlidesArr, thumnailSrc]);
 
    useEffect(() => {
        addImgToSlider();
    }, [addImgToSlider]);
 
    return (
        <div className={cn("min-w-0 flex-shrink-0 flex-grow-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(
                "relative h-2 w-96 max-w-[90%] items-center justify-end overflow-hidden rounded-md bg-gray-500",
                className,
            )}
        >
            <div
                className="absolute top-0 bottom-0 -left-[100%] w-full bg-black dark:bg-white"
                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(
                "flex items-center gap-1 overflow-hidden mix-blend-difference",
                className,
            )}
        >
            <motion.div
                key={selectedSnap}
                custom={direction}
                initial={{ y: direction * 20, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                exit={{ y: -direction * 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 m-0 inline-flex h-2 w-10 p-0`}
                        >
                            <div className="h-2 w-10 rounded-full bg-gray-500/40"></div>
                            {index === selectedIndex && (
                                <AnimatePresence mode="wait">
                                    <motion.div
                                        transition={{
                                            layout: {
                                                duration: 0.4,
                                                ease: "easeInOut",
                                                delay: 0.04,
                                            },
                                        }}
                                        layoutId={`hover-${carouselId}`}
                                        className={cn(
                                            "absolute top-0 left-0 z-[3] h-full w-full rounded-full bg-black dark:bg-white",
                                            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="mt-2 overflow-hidden" ref={emblaThumbsRef}>
            <div className="flex flex-row gap-2">
                {slidesrArr.map((slide, index) => (
                    <div
                        key={`thumb-${index}`}
                        className={`aspect-auto w-full min-w-0 rounded-md border-2 xl:h-24 ${
                            index === selectedIndex
                                ? "opacity-100"
                                : "border-transparent opacity-30"
                        }`}
                        style={{ flex: "0 0 15%" }}
                        onClick={() => onThumbClick(index)}
                    >
                        <motion.img
                            src={slide.src}
                            className="h-full w-full rounded-sm object-cover"
                            width={400}
                            height={400}
                            alt={slide.alt || `Thumbnail ${index + 1}`}
                        />
                    </div>
                ))}
            </div>
        </div>
    );
};
export default Carousel;
 
/*eslint-enable @typescript-eslint/no-explicit-any*/

Update the import paths to match your project setup.