All components

Thumbnail Slider

Thumbnail Slider is a React and Tailwind CSS form component from UI Layouts, licensed MIT. Copy it and paste it into your project.

UI Layouts MIT forms

What it is

Thumbnail Slider is a React and Tailwind CSS form component from UI Layouts, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

Needs

npm i embla-carousel lucide-react embla-carousel-class-names embla-carousel-react embla-carousel-autoplay motion

Licence

This component comes from UI Layouts and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Thumbnail Slider

./registry/components/carousel/thumnail-slider.tsx
'use client';
import { Carousel, Slider, SliderContainer, ThumbsSlider } from '@/components/ui/carousel';
import type { EmblaOptionsType } from 'embla-carousel';

function ThumnailSlider() {
  const OPTIONS: EmblaOptionsType = { loop: false };
  return (
    <>
      <div className='w-[90%] mx-auto'>
        <Carousel options={OPTIONS} className='relative'>
          <SliderContainer className='gap-2'>
            <Slider
              className='xl:h-[400px] sm:h-[350px] h-[300px] w-full'
              thumbnailSrc={
                'https://images.unsplash.com/photo-1759395073808-17782f3d8d66?q=80&w=1471&auto=format&fit=crop'
              }
            >
              <img
                src='https://images.unsplash.com/photo-1759395073808-17782f3d8d66?q=80&w=1471&auto=format&fit=crop'
                width={1400}
                height={800}
                alt='image'
                className='h-full object-cover rounded-lg w-full'
              />
            </Slider>
            <Slider
              className='xl:h-[400px] sm:h-[350px] h-[300px] w-full'
              thumbnailSrc={
                'https://images.unsplash.com/photo-1759434192768-fe3facebd5f6?q=80&w=1471&auto=format&fit=crop'
              }
            >
              <img
                src='https://images.unsplash.com/photo-1759434192768-fe3facebd5f6?q=80&w=1471&auto=format&fit=crop'
                width={1400}
                height={800}
                alt='image'
                className='h-full object-cover rounded-lg w-full'
              />
            </Slider>
            <Slider
              className='xl:h-[400px] sm:h-[350px] h-[300px] w-full'
              thumbnailSrc={
                'https://images.unsplash.com/photo-1758641008040-28cdd59ca8fb?q=80&w=687&auto=format&fit=crop'
              }
            >
              <img
                src='https://images.unsplash.com/photo-1758641008040-28cdd59ca8fb?q=80&w=687&auto=format&fit=crop'
                width={687}
                height={800}
                alt='image'
                className='h-full object-cover rounded-lg w-full'
              />
            </Slider>
            <Slider
              className='xl:h-[400px] sm:h-[350px] h-[300px] w-full'
              thumbnailSrc={
                'https://images.unsplash.com/photo-1618220649687-ba860f3176e7?q=80&w=1474&auto=format&fit=crop'
              }
            >
              <img
                src='https://images.unsplash.com/photo-1618220649687-ba860f3176e7?q=80&w=1474&auto=format&fit=crop'
                width={1474}
                height={800}
                alt='image'
                className='h-full object-cover rounded-lg w-full'
              />
            </Slider>
            <Slider
              className='xl:h-[400px] sm:h-[350px] h-[300px] w-full'
              thumbnailSrc={
                'https://images.unsplash.com/photo-1525310072745-f49212b5ac6d?q=80&w=765&auto=format&fit=crop'
              }
            >
              <img
                src='https://images.unsplash.com/photo-1525310072745-f49212b5ac6d?q=80&w=765&auto=format&fit=crop'
                width={765}
                height={800}
                alt='image'
                className='h-full object-cover rounded-lg w-full'
              />
            </Slider>
            <Slider
              className='xl:h-[400px] sm:h-[350px] h-[300px] w-full'
              thumbnailSrc={
                'https://images.unsplash.com/photo-1603338936206-9f7bd3c61cf0?q=80&w=730&auto=format&fit=crop'
              }
            >
              <img
                src='https://images.unsplash.com/photo-1603338936206-9f7bd3c61cf0?q=80&w=730&auto=format&fit=crop'
                width={730}
                height={800}
                alt='image'
                className='h-full object-cover rounded-lg w-full'
              />
            </Slider>
            <Slider
              className='xl:h-[400px] sm:h-[350px] h-[300px] w-full'
              thumbnailSrc={
                'https://images.unsplash.com/photo-1580411788548-eee17fc98883?q=80&w=687&auto=format&fit=crop'
              }
            >
              <img
                src='https://images.unsplash.com/photo-1580411788548-eee17fc98883?q=80&w=687&auto=format&fit=crop'
                width={687}
                height={800}
                alt='image'
                className='h-full object-cover rounded-lg w-full'
              />
            </Slider>
          </SliderContainer>
          <ThumbsSlider className='pb-1 px-1' thumbsClassName='basis-[15%] h-24' />
        </Carousel>
      </div>
    </>
  );
}

export default ThumnailSlider;
./components/ui/carousel.tsx
'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

Similar components