Carousel
The Carousel component is a flexible, feature-rich carousel/slider component that supports navigation controls, dot indicators, thumbnails, multiple animation types, auto-play, looping, and responsive design. It's built with React, TypeScript, and Framer Motion for smooth animations.
Basic Carousel
- Preview
- Code
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
CarouselDots,
} from '@mindfiredigital/ignix-ui';
<Carousel
animation="none"
>
<CarouselContent split={false}>
<CarouselItem>
<div className="flex h-64 items-center justify-center bg-gradient-to-r from-orange-500 to-red-600 text-white text-4xl rounded-lg">
1
</div>
</CarouselItem>
<CarouselItem>
<div className="flex h-64 items-center justify-center bg-gradient-to-r from-emerald-500 to-teal-600 text-white text-4xl rounded-lg">
2
</div>
</CarouselItem>
<CarouselItem>
<div className="flex h-64 items-center justify-center bg-gradient-to-r from-indigo-500 to-purple-600 text-white text-4xl rounded-lg">
3
</div>
</CarouselItem>
</CarouselContent>
<CarouselPrevious variant="default" />
<CarouselNext variant="default" />
<CarouselDots variant="lines" />
</Carousel>
Thumbnails
- Preview
- Code
const thumbnails = [
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=200&h=200&fit=crop',
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=200&h=200&fit=crop',
'https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=200&h=200&fit=crop',
'https://images.unsplash.com/photo-1511497584788-876760111969?w=200&h=200&fit=crop',
];
<Carousel autoPlay>
<CarouselContent split={false}>
{thumbnails.map((thumbnail, index) => (
<CarouselItem key={index}>
<div className="flex h-100 items-center justify-center overflow-hidden rounded-lg">
<img
src={thumbnail.replace("w=200&h=200", "w=800&h=400")}
alt={`Slide ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<CarouselThumbnails
thumbnails={thumbnails}
position="bottom"
size="md"
/>
</Carousel>
Split Carousel
- Preview
- Code
const testimonials = [
{
name: "Michael Chen",
title: "CEO",
company: "InnovateLabs",
avatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200&h=200&fit=crop&crop=face",
avatarAlt: "Michael Chen",
rating: 5,
quote: "This product has completely transformed how we work. The ease of use and powerful features make it indispensable for our team."
},
{
name: "Sarah Johnson",
title: "CTO",
company: "TechFlow Solutions",
avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200&h=200&fit=crop&crop=face",
avatarAlt: "Sarah Johnson",
rating: 5,
quote: "Outstanding performance and reliability. Our development team has seen a 40% increase in productivity since implementing this solution."
},
{
name: "David Martinez",
title: "Product Manager",
company: "CloudScale Inc",
avatar: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200&h=200&fit=crop&crop=face",
avatarAlt: "David Martinez",
rating: 5,
quote: "The intuitive interface and comprehensive documentation made onboarding seamless. Highly recommend to any organization."
},
{
name: "Emily Rodriguez",
title: "Design Director",
company: "Creative Minds Studio",
avatar: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=200&h=200&fit=crop&crop=face",
avatarAlt: "Emily Rodriguez",
rating: 5,
quote: "Beautiful design and exceptional user experience. It's rare to find a tool that combines functionality with such elegant aesthetics."
},
{
name: "James Wilson",
title: "Founder",
company: "StartupHub",
avatar: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=200&h=200&fit=crop&crop=face",
avatarAlt: "James Wilson",
rating: 5,
quote: "As a startup, we needed a solution that scales with us. This platform has exceeded our expectations in every way possible."
}
];
<Carousel animation="scale">
<CarouselContent split>
{testimonials.map((testimonial, index) => (
<CarouselItem key={index}>
<TestimonialCard
size="sm"
variant="default"
animation="slideUp"
avatarPosition="bottom"
>
<TestimonialCardAuthor
name={testimonial.name}
title={testimonial.title}
company={testimonial.company}
avatar={testimonial.avatar}
avatarAlt={testimonial.avatarAlt}
/>
<TestimonialCardSocialLinks>
<ButtonWithIcon variant="ghost" size="lg" icon={<FaFacebook />} />
<ButtonWithIcon variant="ghost" size="lg" icon={<FaInstagram />} />
<ButtonWithIcon variant="ghost" size="lg" icon={<FaLinkedin />} />
</TestimonialCardSocialLinks>
<TestimonialCardRating value={testimonial.rating} />
<TestimonialCardQuote>
{testimonial.quote}
</TestimonialCardQuote>
</TestimonialCard>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious variant="outline" />
<CarouselNext variant="outline" />
</Carousel>
Installation
- CLI
- MANUAL
ignix add component carousel
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { motion, AnimatePresence, type Variants } from "framer-motion";
import { cn } from "../../../utils/cn";
/* -------------------------------------------------------------------------- */
/* INTERFACE */
/* -------------------------------------------------------------------------- */
export type CarouselAnimation = "none" | "fade" | "slide" | "scale" | "slideUp" | "slideDown" | "slideLeft" | "slideRight";
export interface CarouselContentItemProps extends ClassProps{
children: React.ReactNode;
}
export interface CarouselContentProps extends CarouselContentItemProps {
split?: boolean;
}
export interface SizeProps {
size?: "sm" | "md" | "lg";
}
export interface ClassProps {
className?: string;
}
export interface CarouselDotsProps extends ClassProps, SizeProps{
variant?: "dots" | "lines";
position?: "top" | "bottom" | "left" | "right";
}
export interface CarouselNavigationProps extends ClassProps, SizeProps{
variant?: "default" | "outline" | "ghost";
}
export interface CarouselCommonProps {
autoPlay?: boolean;
loop?: boolean;
animation?: CarouselAnimation;
transitionDuration?: number;
}
export interface CarouselProps extends CarouselContentItemProps, CarouselCommonProps{
interval?: number;
pauseOnHover?: boolean;
showDots?: boolean;
}
export interface CarouselThumbnailsProps extends ClassProps, SizeProps, CarouselCommonProps{
thumbnails: string[];
position?: "top" | "bottom" | "left" | "right";
variant?: "default" | "outline" | "minimal";
}
interface CarouselContextValue extends CarouselCommonProps{
containerRef: React.RefObject<HTMLDivElement | null>;
index: number;
setIndex: React.Dispatch<React.SetStateAction<number>>;
slideCount: number;
setSlideCount: React.Dispatch<React.SetStateAction<number>>;
pageCount: number;
dotsPosition?: "top" | "bottom" | "left" | "right";
setDotsPosition: React.Dispatch<React.SetStateAction<"top" | "bottom" | "left" | "right" | undefined>>;
interval?: number;
isPaused?: boolean;
goToSlide: (slideIndex: number) => void;
goToNext: () => void;
goToPrevious: () => void;
animation?: CarouselAnimation;
split?: boolean;
setSplit: React.Dispatch<React.SetStateAction<boolean>>;
isMobile?: boolean;
carouselId: string;
}
/* -------------------------------------------------------------------------- */
/* CONTEXT */
/* -------------------------------------------------------------------------- */
const CarouselContext = React.createContext<CarouselContextValue | null>(null);
function useCarousel() {
const ctx = React.useContext(CarouselContext);
if (!ctx) {
throw new Error("Carousel components must be used inside <Carousel />");
}
return ctx;
}
/* -------------------------------------------------------------------------- */
/* ROOT */
/* -------------------------------------------------------------------------- */
export const Carousel: React.FC<CarouselProps> = ({
children,
className,
autoPlay = false,
interval = 4000,
pauseOnHover = true,
loop = true,
transitionDuration = 500,
animation = "none",
}) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const [index, setIndex] = React.useState(0);
const [slideCount, setSlideCount] = React.useState(0);
const [isPaused, setIsPaused] = React.useState(false);
const [dotsPosition, setDotsPosition] = React.useState<"top" | "bottom" | "left" | "right" | undefined>(undefined);
const isTransitioningRef = React.useRef(false);
const [split, setSplit] = React.useState(false);
const [isMobile, setIsMobile] = React.useState(false);
// Generate unique ID for this carousel instance
const carouselIdRef = React.useRef<string>(`carousel-${Math.random().toString(36).substr(2, 9)}`);
const carouselId = carouselIdRef.current;
// Detect mobile device
React.useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768); // md breakpoint
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
const pageCount = React.useMemo(() => {
const itemsPerPage = split ? (isMobile ? 1 : 3) : 1;
return Math.ceil(slideCount / itemsPerPage);
}, [slideCount, split, isMobile]);
// Navigation functions
const goToSlide = React.useCallback(
(slideIndex: number) => {
if (!containerRef.current || isTransitioningRef.current) return;
let targetIndex = slideIndex;
if (loop) {
targetIndex = ((slideIndex % pageCount) + pageCount) % pageCount;
} else {
targetIndex = Math.max(0, Math.min(slideIndex, pageCount - 1));
}
const container = containerRef.current;
const isVertical = dotsPosition === "left" || dotsPosition === "right";
if (isVertical) {
const height = container.offsetHeight;
container.scrollTo({
top: targetIndex * height,
behavior: "smooth",
});
} else {
const width = container.offsetWidth;
const itemsPerPage = split ? (isMobile ? 1 : 3) : 1;
if (split) {
// In split mode, find the first item of the target page and scroll to its position
// Use requestAnimationFrame to ensure DOM is fully laid out
requestAnimationFrame(() => {
const firstItemIndex = targetIndex * itemsPerPage;
const items = container.querySelectorAll('[role="group"][aria-roledescription="slide"]');
if (items[firstItemIndex]) {
const targetItem = items[firstItemIndex] as HTMLElement;
// Calculate scroll position: item's position relative to container + current scroll
const containerRect = container.getBoundingClientRect();
const itemRect = targetItem.getBoundingClientRect();
// Item's left edge relative to container's left edge (in viewport coordinates)
const itemLeftInViewport = itemRect.left - containerRect.left;
// Add current scroll to get absolute scroll position
const targetScrollLeft = container.scrollLeft + itemLeftInViewport;
container.scrollTo({
left: Math.max(0, targetScrollLeft),
behavior: "smooth",
});
} else {
// Fallback: calculate based on item width (may not account for gaps perfectly)
const scrollAmount = width;
container.scrollTo({
left: targetIndex * scrollAmount,
behavior: "smooth",
});
}
});
} else {
// Non-split mode: scroll by page width
const scrollAmount = width / itemsPerPage;
container.scrollTo({
left: targetIndex * scrollAmount,
behavior: "smooth",
});
}
}
setIndex(targetIndex);
isTransitioningRef.current = true;
setTimeout(() => {
isTransitioningRef.current = false;
}, transitionDuration);
},
[loop, pageCount, transitionDuration, dotsPosition, split, isMobile]
);
const goToNext = React.useCallback(() => {
if (!containerRef.current || isTransitioningRef.current) return;
if (loop) {
goToSlide(index + 1);
} else if (index < pageCount - 1) {
goToSlide(index + 1);
}
}, [index, loop, pageCount, goToSlide]);
const goToPrevious = React.useCallback(() => {
if (!containerRef.current || isTransitioningRef.current) return;
if (loop) {
goToSlide(index - 1);
} else if (index > 0) {
goToSlide(index - 1);
}
}, [index, loop, goToSlide]);
/* -------------------------------- AUTOPLAY ------------------------------- */
React.useEffect(() => {
if (!autoPlay || isPaused || pageCount <= 1) return;
const id = setInterval(() => {
goToNext();
}, interval);
return () => clearInterval(id);
}, [autoPlay, isPaused, pageCount, interval, goToNext]);
// Handle seamless looping with scroll position reset
React.useEffect(() => {
if (!containerRef.current || !loop || pageCount <= 1) return;
const container = containerRef.current;
const isVertical = dotsPosition === "left" || dotsPosition === "right";
const handleScroll = () => {
if (isTransitioningRef.current) return;
if (isVertical) {
const height = container.offsetHeight;
const scrollTop = container.scrollTop;
// For vertical carousel, each item is 100% height, so divide by height
const currentPage = Math.round(scrollTop / height);
// Reset position for seamless loop
if (currentPage >= pageCount) {
container.scrollTo({
top: 0,
behavior: "auto",
});
setIndex(0);
} else if (currentPage < 0) {
container.scrollTo({
top: (pageCount - 1) * height,
behavior: "auto",
});
setIndex(pageCount - 1);
} else {
setIndex(currentPage);
}
} else {
const width = container.offsetWidth;
const scrollLeft = container.scrollLeft;
const itemsPerPage = split ? (isMobile ? 1 : 3) : 1;
let currentPage: number;
if (split) {
// In split mode, determine page based on which item is at the left edge
// Find the item whose left edge is closest to the container's left edge
const items = container.querySelectorAll('[role="group"][aria-roledescription="slide"]');
const containerRect = container.getBoundingClientRect();
let leftmostItemIndex = 0;
let minDistance = Infinity;
items.forEach((item, idx) => {
const itemElement = item as HTMLElement;
const itemRect = itemElement.getBoundingClientRect();
// Calculate item's position relative to container's left edge
const itemLeftRelative = itemRect.left - containerRect.left;
// We want the item that is at or closest to the left edge (within a threshold)
// Prefer items that are at the left edge (0 or slightly positive)
const distance = Math.abs(itemLeftRelative);
if (distance < minDistance || (itemLeftRelative >= 0 && itemLeftRelative < 10)) {
minDistance = distance;
leftmostItemIndex = idx;
}
});
// Calculate which page this item belongs to
currentPage = Math.floor(leftmostItemIndex / itemsPerPage);
} else {
// Non-split mode: calculate based on scroll amount
const scrollAmount = width / itemsPerPage;
currentPage = Math.round(scrollLeft / scrollAmount);
}
// Reset position for seamless loop
if (currentPage >= pageCount) {
container.scrollTo({
left: 0,
behavior: "auto",
});
setIndex(0);
} else if (currentPage < 0) {
if (split) {
// In split mode, find the last page's first item
const items = container.querySelectorAll('[role="group"][aria-roledescription="slide"]');
const lastPageFirstItemIndex = (pageCount - 1) * itemsPerPage;
if (items[lastPageFirstItemIndex]) {
const targetItem = items[lastPageFirstItemIndex] as HTMLElement;
const containerRect = container.getBoundingClientRect();
const itemRect = targetItem.getBoundingClientRect();
const itemLeftInViewport = itemRect.left - containerRect.left;
const targetScrollLeft = container.scrollLeft + itemLeftInViewport;
container.scrollTo({
left: Math.max(0, targetScrollLeft),
behavior: "auto",
});
} else {
container.scrollTo({
left: (pageCount - 1) * width,
behavior: "auto",
});
}
} else {
const scrollAmount = width / itemsPerPage;
container.scrollTo({
left: (pageCount - 1) * scrollAmount,
behavior: "auto",
});
}
setIndex(pageCount - 1);
} else {
setIndex(currentPage);
}
}
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, [loop, pageCount, dotsPosition, split, isMobile]);
const hasOverflowVisible = className?.includes("overflow-visible");
// Add style for mobile button layout to keep buttons in same row
React.useEffect(() => {
if (!isMobile) return;
const styleId = "carousel-mobile-buttons-style";
let styleElement = document.getElementById(styleId);
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = styleId;
document.head.appendChild(styleElement);
}
styleElement.textContent = `
@media (max-width: 767px) {
[role="region"][aria-label="Carousel"] {
display: block !important;
text-align: center;
}
[role="region"][aria-label="Carousel"] > button[aria-label*="slide"],
[role="region"][aria-label="Carousel"] > button[aria-label*="Previous"],
[role="region"][aria-label="Carousel"] > button[aria-label*="Next"] {
display: inline-flex !important;
vertical-align: middle;
}
}
`;
return () => {
if (!isMobile) {
const element = document.getElementById(styleId);
if (element) {
element.remove();
}
}
};
}, [isMobile]);
return (
<CarouselContext.Provider
value={{
containerRef,
index,
setIndex,
slideCount,
setSlideCount,
pageCount,
dotsPosition,
setDotsPosition,
loop,
autoPlay,
interval,
isPaused,
animation,
transitionDuration,
goToSlide,
goToNext,
goToPrevious,
split,
setSplit,
isMobile,
carouselId,
}}
>
<div
className={cn(
"relative w-full",
(hasOverflowVisible && animation === "none") || split
? "overflow-visible"
: "overflow-hidden",
split && !isMobile && "px-12 sm:px-14",
className
)}
onMouseEnter={(e) => {
// Don't pause if hovering over navigation buttons or their children
const target = e.target as HTMLElement;
const isNavigationButton = target.closest('button[aria-label*="slide"]') ||
target.closest('button[aria-label*="Previous"]') ||
target.closest('button[aria-label*="Next"]') ||
target.closest('button[aria-label*="Previous slide"]') ||
target.closest('button[aria-label*="Next slide"]');
if (!isNavigationButton && pauseOnHover) {
setIsPaused(true);
}
}}
onMouseMove={(e) => {
// Also check on mouse move to handle cases where mouse enters from button
const target = e.target as HTMLElement;
const isNavigationButton = target.closest('button[aria-label*="slide"]') ||
target.closest('button[aria-label*="Previous"]') ||
target.closest('button[aria-label*="Next"]') ||
target.closest('button[aria-label*="Previous slide"]') ||
target.closest('button[aria-label*="Next slide"]');
if (isNavigationButton && isPaused && pauseOnHover) {
setIsPaused(false);
}
}}
onMouseLeave={() => pauseOnHover && setIsPaused(false)}
role="region"
aria-label="Carousel"
tabIndex={0}
>
{children}
</div>
</CarouselContext.Provider>
);
}
/* -------------------------------------------------------------------------- */
/* CONTENT */
/* -------------------------------------------------------------------------- */
export const CarouselContent: React.FC<CarouselContentProps> = ({
children,
className,
split = false,
}) => {
const { containerRef, setSlideCount, dotsPosition, animation, index, setSplit, split: contextSplit, isMobile } = useCarousel();
React.useEffect(() => {
setSplit(split);
}, [split, setSplit]);
React.useEffect(() => {
setSlideCount(React.Children.count(children));
}, [children, setSlideCount]);
const isVertical = React.useMemo(
() => dotsPosition === "left" || dotsPosition === "right",
[dotsPosition]
);
// Prevent mouse wheel scrolling
const handleWheel = React.useCallback((e: React.WheelEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const childrenArray = React.Children.toArray(children);
// Calculate visible items for animation with split mode
const getVisibleItems = React.useMemo(() => {
if (animation === "none") return null;
const isSplitMode = contextSplit || split;
if (!isSplitMode) {
// Non-split mode: show only the active item
return childrenArray.length > 0
? [childrenArray[Math.min(index, childrenArray.length - 1)]]
: [];
}
// Split mode: show multiple items based on current page
const itemsPerPage = isMobile ? 1 : 3;
const startIndex = index * itemsPerPage;
const visibleItems: React.ReactNode[] = [];
for (let i = 0; i < itemsPerPage && startIndex + i < childrenArray.length; i++) {
visibleItems.push(childrenArray[startIndex + i]);
}
return visibleItems;
}, [animation, contextSplit, split, isMobile, index, childrenArray]);
return (
<div
ref={containerRef}
onWheel={handleWheel}
className={cn(
"snap-mandatory scroll-smooth [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
animation !== "none"
? "relative w-full overflow-hidden"
: cn(
"flex",
isVertical
? "flex-col h-full snap-y overflow-y-hidden w-full"
: "flex-row w-full snap-x overflow-x-hidden"
),
className
)}
style={
animation !== "none"
? { position: 'relative' } as React.CSSProperties
: isVertical
? { height: '100%', minHeight: '100%' }
: { overflowX: 'hidden' } as React.CSSProperties
}
tabIndex={0}
role="group"
aria-label="Carousel content"
>
{animation !== "none" ? (
<AnimatePresence mode="wait" initial={true}>
{getVisibleItems && getVisibleItems.length > 0 && (
<motion.div
key={`${index}-${animation}`}
className={cn(
"flex w-full",
(contextSplit || split) && !isMobile ? "flex-row" : "flex-col"
)}
initial="initial"
animate="animate"
exit="exit"
variants={{
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
}}
transition={{ duration: 0.3 }}
>
{getVisibleItems.map((child, i) =>
React.isValidElement(child)
? React.cloneElement(child, { key: `item-${index}-${i}-${animation}` })
: child
)}
</motion.div>
)}
</AnimatePresence>
) : (
children
)}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* ITEM */
/* -------------------------------------------------------------------------- */
export const CarouselItem: React.FC<CarouselContentItemProps> = ({
children,
className,
}) => {
const { dotsPosition, animation = "none", transitionDuration = 500, split = false, isMobile = false, carouselId } = useCarousel();
const itemRef = React.useRef<HTMLDivElement>(null);
const isVertical = dotsPosition === "left" || dotsPosition === "right";
const itemClass = `carousel-item-${carouselId}-${isVertical ? "vertical" : "horizontal"}`;
React.useEffect(() => {
const styleId = `carousel-item-style-${carouselId}-${isVertical ? "vertical" : "horizontal"}`;
let styleElement = document.getElementById(styleId);
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = styleId;
document.head.appendChild(styleElement);
}
if (isVertical) {
styleElement.textContent = `
.${itemClass} {
flex: 0 0 100%;
min-height: 100%;
}
`;
} else {
const width = split ? (isMobile ? "100%" : "33.333333%") : "100%";
styleElement.textContent = `
.${itemClass} {
flex: 0 0 ${width};
min-width: ${width};
width: ${width};
}
`;
}
return () => {
const elements = document.querySelectorAll(`.${itemClass}`);
if (elements.length === 0) {
const element = document.getElementById(styleId);
if (element) {
element.remove();
}
}
};
}, [itemClass, isVertical, split, isMobile, carouselId]);
// Animation variants
const getAnimationVariants = (): Variants => {
switch (animation) {
case "fade":
return {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
case "slide":
return {
initial: { x: isVertical ? 0 : 50, opacity: 0 },
animate: { x: 0, opacity: 1 },
exit: { x: isVertical ? 0 : -50, opacity: 0 },
};
case "scale":
return {
initial: { scale: 0.8, opacity: 0 },
animate: { scale: 1, opacity: 1 },
exit: { scale: 0.8, opacity: 0 },
};
case "slideUp":
return {
initial: { y: 30, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -30, opacity: 0 },
};
case "slideDown":
return {
initial: { y: -30, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: 30, opacity: 0 },
};
case "slideLeft":
return {
initial: { x: 50, opacity: 0 },
animate: { x: 0, opacity: 1 },
exit: { x: -50, opacity: 0 },
};
case "slideRight":
return {
initial: { x: -50, opacity: 0 },
animate: { x: 0, opacity: 1 },
exit: { x: 50, opacity: 0 },
};
default:
return {
initial: {},
animate: {},
exit: {},
};
}
};
const variants = getAnimationVariants();
const transition = {
duration: transitionDuration / 1000,
ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
};
const baseClasses = cn(
itemClass,
animation === "none"
? "snap-start flex-grow-0 shrink-0 transition-transform duration-500 ease-in-out"
: split
? "relative flex-shrink-0" // Width controlled by style element in split mode
: "relative w-full",
className
);
const baseProps = {
className: baseClasses,
role: "group" as const,
"aria-roledescription": "slide" as const,
};
if (animation === "none") {
return (
<div ref={itemRef} {...baseProps}>
{children}
</div>
);
}
return (
<motion.div
ref={itemRef}
{...baseProps}
initial="initial"
animate="animate"
exit="exit"
variants={variants}
transition={transition}
style={{
willChange: "transform, opacity",
width: "100%",
}}
>
{children}
</motion.div>
);
}
/* -------------------------------------------------------------------------- */
/* NAVIGATION */
/* -------------------------------------------------------------------------- */
export const CarouselPrevious: React.FC<CarouselNavigationProps> = ({
className,
variant = "default",
size = "md",
}) => {
const { index, goToPrevious, dotsPosition, split = false, isMobile = false } = useCarousel();
// Disable Previous button at first slide (regardless of loop setting)
const isDisabled = index === 0;
const isVertical = dotsPosition === "left" || dotsPosition === "right";
const sizeClasses = {
sm: "p-1.5",
md: "p-2",
lg: "p-2.5",
};
const variantClasses = {
default: "bg-black/40 hover:bg-black/60 text-white",
outline: "bg-white/90 hover:bg-white border border-gray-300 text-gray-700",
ghost: "bg-transparent hover:bg-black/20 text-gray-700 dark:text-gray-300",
};
// Check if custom positioning is provided via className
const hasCustomPosition = className?.includes("left-") || className?.includes("-left-") || className?.includes("right-") || className?.includes("-right-");
const getLeftPosition = () => {
if (hasCustomPosition) return "";
if (isMobile) {
return "relative mt-4 inline-flex mr-2";
}
if (split) {
return "absolute left-0 top-1/2 -translate-y-1/2 z-10";
}
return "absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 z-10";
};
const positionClasses = isVertical
? isMobile
? "relative mt-4 inline-flex mr-2"
: "absolute top-2 sm:top-3 left-1/2 -translate-x-1/2 z-20"
: hasCustomPosition
? isMobile
? "relative mt-4 inline-flex mr-2"
: "absolute top-1/2 -translate-y-1/2 z-10"
: getLeftPosition();
return (
<button
type="button"
aria-label="Previous slide"
onClick={goToPrevious}
disabled={isDisabled}
onMouseEnter={(e) => {
e.stopPropagation();
}}
className={cn(
positionClasses,
"cursor-pointer rounded-full transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed",
sizeClasses[size],
variantClasses[variant],
className
)}
aria-disabled={isDisabled}
>
{isVertical ? (
<ChevronLeft className={cn(
size === "sm" ? "w-4 h-4 sm:w-5 sm:h-5" :
size === "lg" ? "w-6 h-6 sm:w-7 sm:h-7" :
"w-5 h-5 sm:w-6 sm:h-6 rotate-90"
)} />
) : (
<ChevronLeft className={size === "sm" ? "w-4 h-4 sm:w-5 sm:h-5" : size === "lg" ? "w-6 h-6 sm:w-7 sm:h-7" : "w-5 h-5 sm:w-6 sm:h-6"} />
)}
</button>
);
}
export const CarouselNext: React.FC<CarouselNavigationProps> = ({
className,
variant = "default",
size = "md",
}) => {
const { index, pageCount, goToNext, dotsPosition, split = false, isMobile = false } = useCarousel();
// Disable Next button at last slide (regardless of loop setting)
const isDisabled = pageCount > 0 && index >= pageCount - 1;
const isVertical = dotsPosition === "left" || dotsPosition === "right";
const sizeClasses = {
sm: "p-1.5 sm:p-2",
md: "p-2 sm:p-2.5",
lg: "p-2.5 sm:p-3",
};
const variantClasses = {
default: "bg-black/40 hover:bg-black/60 text-white",
outline: "bg-white/90 hover:bg-white border border-gray-300 text-gray-700",
ghost: "bg-transparent hover:bg-black/20 text-gray-700 dark:text-gray-300",
};
// Check if custom positioning is provided via className
const hasCustomPosition = className?.includes("left-") || className?.includes("-left-") || className?.includes("right-") || className?.includes("-right-");
const getRightPosition = () => {
if (hasCustomPosition) return "";
if (isMobile) {
return "relative mt-4 inline-flex";
}
if (split) {
return "absolute right-0 top-1/2 -translate-y-1/2 z-10";
}
return "absolute right-2 sm:right-3 top-1/2 -translate-y-1/2 z-10";
};
const positionClasses = isVertical
? isMobile
? "relative mt-4 inline-flex"
: "absolute bottom-2 sm:bottom-3 left-1/2 -translate-x-1/2 z-20"
: hasCustomPosition
? isMobile
? "relative mt-4 inline-flex"
: "absolute top-1/2 -translate-y-1/2 z-10"
: getRightPosition();
return (
<button
type="button"
aria-label="Next slide"
onClick={goToNext}
disabled={isDisabled}
onMouseEnter={(e) => {
e.stopPropagation();
}}
className={cn(
positionClasses,
"cursor-pointer rounded-full transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed",
sizeClasses[size],
variantClasses[variant],
className
)}
aria-disabled={isDisabled}
>
{isVertical ? (
<ChevronRight className={cn(
size === "sm" ? "w-4 h-4 sm:w-5 sm:h-5" :
size === "lg" ? "w-6 h-6 sm:w-7 sm:h-7" :
"w-5 h-5 sm:w-6 sm:h-6 rotate-90"
)} />
) : (
<ChevronRight className={size === "sm" ? "w-4 h-4 sm:w-5 sm:h-5" : size === "lg" ? "w-6 h-6 sm:w-7 sm:h-7" : "w-5 h-5 sm:w-6 sm:h-6"} />
)}
</button>
);
}
/* -------------------------------------------------------------------------- */
/* DOTS */
/* -------------------------------------------------------------------------- */
export const CarouselDots: React.FC<CarouselDotsProps> = ({
className,
variant = "default",
size = "md",
position = "bottom",
}) => {
const { index, pageCount, goToSlide, setDotsPosition} = useCarousel();
// Update dots position in context so navigation buttons can use it
React.useEffect(() => {
setDotsPosition(position);
}, [position, setDotsPosition]);
// Early return AFTER all hooks
if (pageCount <= 1) return null;
const isVertical = position === "left" || position === "right";
// Size classes - swap width/height for vertical orientation when variant is not "dots"
const sizeClasses = {
sm: variant === "dots"
? "h-1.5 w-1.5"
: isVertical ? "w-1 h-4" : "h-1 w-4",
md: variant === "dots"
? "h-2 w-2"
: isVertical ? "w-1.5 h-6" : "h-1.5 w-6",
lg: variant === "dots"
? "h-2.5 w-2.5"
: isVertical ? "w-2 h-8" : "h-2 w-8",
};
const activeSizeClasses = {
sm: variant === "dots"
? "h-1.5 w-1.5"
: isVertical ? "w-1 h-8" : "h-1 w-8",
md: variant === "dots"
? "h-2 w-2"
: isVertical ? "w-1.5 h-10" : "h-1.5 w-10",
lg: variant === "dots"
? "h-2.5 w-2.5"
: isVertical ? "w-2 h-12" : "h-2 w-12",
};
const positionClasses: Record<"top" | "bottom" | "left" | "right", string> = {
top: "top-4 left-1/2 -translate-x-1/2",
bottom: "bottom-4 left-1/2 -translate-x-1/2",
left: "left-4 top-1/2 -translate-y-1/2",
right: "right-4 top-1/2 -translate-y-1/2",
};
const getDotClasses = (i: number) => {
if (i === index) {
return cn(
"bg-white shadow-md",
activeSizeClasses[size]
);
}
return cn(
"bg-white/50 hover:bg-white/70",
sizeClasses[size]
);
};
return (
<div
className={cn(
"absolute gap-2 z-10",
isVertical ? "flex flex-col" : "flex flex-row",
positionClasses[position],
className
)}
role="tablist"
aria-label="Carousel navigation dots"
>
{Array.from({ length: pageCount }).map((_, i) => {
const isActive = i === index;
return (
<button
key={i}
type="button"
role="tab"
aria-label={`Go to slide ${i + 1}`}
aria-selected={isActive}
onClick={() => goToSlide(i)}
className={cn(
"rounded-full transition-all duration-300 relative focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary hover:scale-110",
variant === "dots" ? "rounded-full" : "rounded-sm",
getDotClasses(i),
)}
/>
);
})}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* THUMBNAILS */
/* -------------------------------------------------------------------------- */
export const CarouselThumbnails: React.FC<CarouselThumbnailsProps> = ({
thumbnails,
className,
position = "bottom",
size = "md",
variant = "default",
}) => {
const { index, pageCount, goToSlide } = useCarousel();
const thumbnailContainerRef = React.useRef<HTMLDivElement>(null);
const [visibleThumbnails, setVisibleThumbnails] = React.useState(3);
// Calculate visible thumbnails based on screen size
React.useEffect(() => {
const updateVisibleThumbnails = () => {
const width = window.innerWidth;
if (width >= 1024) {
setVisibleThumbnails(5);
} else if (width >= 768) {
setVisibleThumbnails(4);
} else if (width >= 640) {
setVisibleThumbnails(3);
} else {
setVisibleThumbnails(2);
}
};
updateVisibleThumbnails();
window.addEventListener("resize", updateVisibleThumbnails);
return () => window.removeEventListener("resize", updateVisibleThumbnails);
}, []);
// Auto-scroll thumbnails to keep active one visible
React.useEffect(() => {
if (!thumbnailContainerRef.current || thumbnails.length <= visibleThumbnails) return;
const container = thumbnailContainerRef.current;
const isVertical = position === "left" || position === "right";
const scrollProperty = isVertical ? "scrollTop" : "scrollLeft";
const clientSizeProperty = isVertical ? "clientHeight" : "clientWidth";
const activeButton = container.querySelector(`[data-thumbnail-index="${index}"]`) as HTMLElement;
if (!activeButton) return;
const scrollPosition = container[scrollProperty];
const activePosition = isVertical ? activeButton.offsetTop : activeButton.offsetLeft;
const activeSize = isVertical ? activeButton.offsetHeight : activeButton.offsetWidth;
const visibleStart = scrollPosition;
const visibleEnd = scrollPosition + container[clientSizeProperty];
// Scroll to show active thumbnail if it's not visible
if (activePosition < visibleStart) {
container[scrollProperty] = activePosition - (isVertical ? 8 : 8);
} else if (activePosition + activeSize > visibleEnd) {
container[scrollProperty] = activePosition + activeSize - container[clientSizeProperty] + (isVertical ? 8 : 8);
}
}, [index, thumbnails.length, visibleThumbnails, position]);
// Early return check AFTER all hooks
if (pageCount <= 1 || !thumbnails || thumbnails.length === 0) return null;
const sizeClasses = {
sm: "w-12 h-12",
md: "w-16 h-16",
lg: "w-20 h-20",
};
const variantClasses = {
default: {
active: "border-2 border-white ring-2 ring-primary shadow-lg scale-105",
inactive: "border-2 border-white/30 hover:border-white/60 hover:scale-105",
},
outline: {
active: "border-2 border-primary ring-2 ring-primary/20 shadow-md scale-105",
inactive: "border-2 border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500 hover:scale-105",
},
minimal: {
active: "border-2 border-primary opacity-100 scale-105",
inactive: "border-2 border-transparent opacity-60 hover:opacity-80 hover:scale-105",
},
};
const isVertical = position === "left" || position === "right";
const needsScrolling = thumbnails.length > visibleThumbnails;
const positionClasses = {
top: "top-4 left-1/2 -translate-x-1/2 flex-row",
bottom: "bottom-4 left-1/2 -translate-x-1/2 flex-row",
left: "left-4 top-1/2 -translate-y-1/2 flex-col",
right: "right-4 top-1/2 -translate-y-1/2 flex-col",
};
const gapClasses = {
top: "gap-2",
bottom: "gap-2",
left: "gap-2",
right: "gap-2",
};
const scrollClasses = needsScrolling
? isVertical
? "overflow-y-auto max-h-[400px] snap-y snap-mandatory"
: "overflow-x-auto max-w-[90vw] snap-x snap-mandatory"
: "";
return (
<div
ref={thumbnailContainerRef}
className={cn(
"absolute z-10 flex",
positionClasses[position],
gapClasses[position],
scrollClasses,
needsScrolling && "[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
className
)}
role="tablist"
aria-label="Carousel thumbnails"
>
{thumbnails.slice(0, pageCount).map((thumbnail, i) => {
const isActive = i === index;
return (
<button
key={i}
data-thumbnail-index={i}
type="button"
role="tab"
aria-label={`Go to slide ${i + 1}`}
aria-selected={isActive}
onClick={() => goToSlide(i)}
className={cn(
"rounded-lg overflow-hidden transition-all duration-200 flex-shrink-0",
"focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary",
sizeClasses[size],
isActive
? variantClasses[variant].active
: variantClasses[variant].inactive
)}
>
<img
src={thumbnail}
alt={`Thumbnail ${i + 1}`}
className="w-full h-full object-cover"
loading="lazy"
/>
</button>
);
})}
</div>
);
}
Usage
Basic Example
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
CarouselDots,
} from '@ignix-ui/carousel';
function MyCarousel() {
return (
<Carousel>
<CarouselContent>
<CarouselItem>
<div className="flex h-64 items-center justify-center bg-gradient-to-r from-indigo-500 to-purple-600 text-white">
Slide One
</div>
</CarouselItem>
<CarouselItem>
<div className="flex h-64 items-center justify-center bg-gradient-to-r from-emerald-500 to-teal-600 text-white">
Slide Two
</div>
</CarouselItem>
<CarouselItem>
<div className="flex h-64 items-center justify-center bg-gradient-to-r from-orange-500 to-red-600 text-white">
Slide Three
</div>
</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<CarouselDots />
</Carousel>
);
}
With Auto-Play
<Carousel autoPlay interval={3000}>
<CarouselContent>
{/* Carousel items */}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<CarouselDots />
</Carousel>
With Animations
<Carousel animation="fade" transitionDuration={500}>
<CarouselContent>
{/* Carousel items */}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<CarouselDots />
</Carousel>
With Thumbnails
const thumbnails = [
'https://example.com/thumb1.jpg',
'https://example.com/thumb2.jpg',
'https://example.com/thumb3.jpg',
];
<Carousel>
<CarouselContent>
{thumbnails.map((thumbnail, index) => (
<CarouselItem key={index}>
<img src={thumbnail} alt={`Slide ${index + 1}`} />
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<CarouselThumbnails thumbnails={thumbnails} />
</Carousel>
Props
The main carousel container component.
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | - | Carousel content and controls |
className | string | - | Additional CSS classes |
autoPlay | boolean | false | Enable auto-play |
interval | number | 4000 | Auto-play interval in milliseconds |
pauseOnHover | boolean | true | Pause auto-play on hover |
loop | boolean | true | Enable infinite loop |
animation | "none" | "fade" | "slide" | "scale" | "slideUp" | "slideDown" | "slideLeft" | "slideRight" | "none" | Animation type |
transitionDuration | number | 500 | Animation duration in milliseconds |
CarouselContent
Container for carousel items.
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | - | Carousel items |
className | string | - | Additional CSS classes |
split | boolean | false | Enable split mode to show multiple items (3 on desktop, 1 on mobile) |
CarouselItem
Individual carousel slide/item.
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | - | Item content |
className | string | - | Additional CSS classes |
CarouselPrevious
Previous navigation button.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes |
variant | "default" | "outline" | "ghost" | "default" | Button variant |
size | "sm" | "md" | "lg" | "md" | Button size |
CarouselNext
Next navigation button.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes |
variant | "default" | "outline" | "ghost" | "default" | Button variant |
size | "sm" | "md" | "lg" | "md" | Button size |
CarouselDots
Dot indicators for carousel navigation.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes |
variant | "dots" | "lines" | "dots" | Dots variant |
position | "top" | "bottom" | "left" | "right" | "bottom" | Dots position (left/right creates vertical carousel) |
size | "sm" | "md" | "lg" | "md" | Dots size |
CarouselThumbnails
Thumbnail navigation for carousel.
| Prop | Type | Default | Description |
|---|---|---|---|
thumbnails | string[] | - | Array of thumbnail image URLs (required) |
className | string | - | Additional CSS classes |
position | "top" | "bottom" | "left" | "right" | "bottom" | Thumbnails position |
variant | "default" | "outline" | "minimal" | "default" | Thumbnails variant |
size | "sm" | "md" | "lg" | "md" | Thumbnails size |
autoPlay | boolean | - | Inherit from Carousel |
loop | boolean | - | Inherit from Carousel |
animation | CarouselAnimation | - | Inherit from Carousel |
transitionDuration | number | - | Inherit from Carousel |
Features
- Navigation Controls: Previous/Next buttons with customizable variants
- Dot Indicators: Multiple variants (dots, lines) and positions (top, bottom, left, right)
- Thumbnail Navigation: Image thumbnails for visual navigation
- Animations: Fade, slide, scale, and directional slide animations using Framer Motion
- Auto-Play: Configurable auto-play with pause on hover
- Infinite Loop: Seamless looping through carousel items
- Split Mode: Show multiple items at once (3 on desktop, 1 on mobile) using the
splitprop - Vertical Orientation: Support for vertical carousel when dots are positioned at left/right
- Keyboard Navigation: Arrow keys for navigation
- Accessibility: ARIA labels and keyboard support
Animation Types
- none: No animation (default, best for split mode)
- fade: Fade in/out transition
- slide: Horizontal slide animation
- scale: Scale in/out animation
- slideUp: Slide up from bottom
- slideDown: Slide down from top
- slideLeft: Slide in from right
- slideRight: Slide in from left