Align Slider
Align 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
Align 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.
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.
Align Slider
'use client';
import { Carousel, Slider, SliderContainer, SliderProgress } from '@/components/ui/carousel';
import { imgPreview } from '@/components/website/constant';
import type { EmblaOptionsType } from 'embla-carousel';
import Image from 'next/image';
import React, { ReactNode } from 'react';
function AlignSlider() {
const OPTIONS: EmblaOptionsType = { loop: false };
return (
<>
<Carousel className='bg-transparent sm:px-10 px-2' options={OPTIONS}>
<SliderContainer className='gap-2'>
<Slider className='w-4/5 sm:w-1/2 lg:w-2/5 xl:w-3/5 2xl:w-[50%]'>
<div className='bg-red-500 2xl:h-[400px] xl:h-[300px] h-[250px] w-full rounded-xl'></div>{' '}
</Slider>
<Slider className='w-4/5 sm:w-1/2 lg:w-2/5 xl:w-2/5 2xl:w-[40%]'>
<div className='bg-blue-500 2xl:h-[400px] xl:h-[300px] h-[250px] w-full rounded-xl'></div>{' '}
</Slider>
<Slider className='w-4/5 sm:w-1/2 lg:w-1/5 xl:w-2/5 2xl:w-[30%]'>
<div className='bg-green-500 2xl:h-[400px] xl:h-[300px] h-[250px] w-full rounded-xl'></div>{' '}
</Slider>
<Slider className='w-4/5 sm:w-1/2 lg:w-1/5 xl:w-4/5 2xl:w-[80%]'>
<div className='bg-yellow-500 2xl:h-[400px] xl:h-[300px] h-[250px] w-full rounded-xl'></div>{' '}
</Slider>
<Slider className='w-4/5 sm:w-1/2 lg:w-1/5 xl:w-2/5 2xl:w-[50%]'>
<div className='bg-purple-500 2xl:h-[400px] xl:h-[300px] h-[250px] w-full rounded-xl'></div>{' '}
</Slider>
<Slider className='w-4/5 sm:w-1/2 lg:w-1/5 xl:w-2/5 2xl:w-[50%]'>
<div className='bg-pink-500 2xl:h-[400px] xl:h-[300px] h-[250px] w-full rounded-xl'></div>{' '}
</Slider>
</SliderContainer>
<div className='flex justify-center py-2'>
<SliderProgress />
</div>
</Carousel>
</>
);
}
export default AlignSlider;
'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