Carousel
A flexible and easy-to-use carousel with customizable navigation and indicators.
Examples
Carousel basic
1
2
3
4
Carousel with custom sizes
We use basis-1/3
to set the width of each carousel item to one-third of the container.
1
2
3
4
5
6
7
Carousel with custom sizes and spacing
We use -ml-4
on CarouselContent
and pl-4
on CarouselItem
to add spacing between carousel items.
1
2
3
4
5
6
7
Carousel with custom indicators
We can use index
and onIndexChange
to create custom indicators for the carousel.
1
2
3
4
Code
'use client';
import {
Children,
ReactNode,
createContext,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { motion, Transition, useMotionValue } from 'motion/react';
import { cn } from '@/lib/utils';
import { ChevronLeft, ChevronRight } from 'lucide-react';
type CarouselContextType = {
index: number;
setIndex: (newIndex: number) => void;
itemsCount: number;
setItemsCount: (newItemsCount: number) => void;
disableDrag: boolean;
};
const CarouselContext = createContext<CarouselContextType | undefined>(
undefined
);
function useCarousel() {
const context = useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within an CarouselProvider');
}
return context;
}
type CarouselProviderProps = {
children: ReactNode;
initialIndex?: number;
onIndexChange?: (newIndex: number) => void;
disableDrag?: boolean;
};
function CarouselProvider({
children,
initialIndex = 0,
onIndexChange,
disableDrag = false,
}: CarouselProviderProps) {
const [index, setIndex] = useState<number>(initialIndex);
const [itemsCount, setItemsCount] = useState<number>(0);
const handleSetIndex = (newIndex: number) => {
setIndex(newIndex);
onIndexChange?.(newIndex);
};
useEffect(() => {
setIndex(initialIndex);
}, [initialIndex]);
return (
<CarouselContext.Provider
value={{
index,
setIndex: handleSetIndex,
itemsCount,
setItemsCount,
disableDrag,
}}
>
{children}
</CarouselContext.Provider>
);
}
type CarouselProps = {
children: ReactNode;
className?: string;
initialIndex?: number;
index?: number;
onIndexChange?: (newIndex: number) => void;
disableDrag?: boolean;
};
function Carousel({
children,
className,
initialIndex = 0,
index: externalIndex,
onIndexChange,
disableDrag = false,
}: CarouselProps) {
const [internalIndex, setInternalIndex] = useState<number>(initialIndex);
const isControlled = externalIndex !== undefined;
const currentIndex = isControlled ? externalIndex : internalIndex;
const handleIndexChange = (newIndex: number) => {
if (!isControlled) {
setInternalIndex(newIndex);
}
onIndexChange?.(newIndex);
};
return (
<CarouselProvider
initialIndex={currentIndex}
onIndexChange={handleIndexChange}
disableDrag={disableDrag}
>
<div className={cn('group/hover relative', className)}>
<div className='overflow-hidden'>{children}</div>
</div>
</CarouselProvider>
);
}
type CarouselNavigationProps = {
className?: string;
classNameButton?: string;
alwaysShow?: boolean;
};
function CarouselNavigation({
className,
classNameButton,
alwaysShow,
}: CarouselNavigationProps) {
const { index, setIndex, itemsCount } = useCarousel();
return (
<div
className={cn(
'pointer-events-none absolute left-[-12.5%] top-1/2 flex w-[125%] -translate-y-1/2 justify-between px-2',
className
)}
>
<button
type='button'
className={cn(
'pointer-events-auto h-fit w-fit rounded-full bg-zinc-50 p-2 transition-opacity duration-300 dark:bg-zinc-950',
alwaysShow
? 'opacity-100'
: 'opacity-0 group-hover/hover:opacity-100',
alwaysShow
? 'disabled:opacity-40'
: 'disabled:group-hover/hover:opacity-40',
classNameButton
)}
disabled={index === 0}
onClick={() => {
if (index > 0) {
setIndex(index - 1);
}
}}
>
<ChevronLeft
className='stroke-zinc-600 dark:stroke-zinc-50'
size={16}
/>
</button>
<button
type='button'
className={cn(
'pointer-events-auto h-fit w-fit rounded-full bg-zinc-50 p-2 transition-opacity duration-300 dark:bg-zinc-950',
alwaysShow
? 'opacity-100'
: 'opacity-0 group-hover/hover:opacity-100',
alwaysShow
? 'disabled:opacity-40'
: 'disabled:group-hover/hover:opacity-40',
classNameButton
)}
disabled={index + 1 === itemsCount}
onClick={() => {
if (index < itemsCount - 1) {
setIndex(index + 1);
}
}}
>
<ChevronRight
className='stroke-zinc-600 dark:stroke-zinc-50'
size={16}
/>
</button>
</div>
);
}
type CarouselIndicatorProps = {
className?: string;
classNameButton?: string;
};
function CarouselIndicator({
className,
classNameButton,
}: CarouselIndicatorProps) {
const { index, itemsCount, setIndex } = useCarousel();
return (
<div
className={cn(
'absolute bottom-0 z-10 flex w-full items-center justify-center',
className
)}
>
<div className='flex space-x-2'>
{Array.from({ length: itemsCount }, (_, i) => (
<button
key={i}
type='button'
aria-label={`Go to slide ${i + 1}`}
onClick={() => setIndex(i)}
className={cn(
'h-2 w-2 rounded-full transition-opacity duration-300',
index === i
? 'bg-zinc-950 dark:bg-zinc-50'
: 'bg-zinc-900/50 dark:bg-zinc-100/50',
classNameButton
)}
/>
))}
</div>
</div>
);
}
type CarouselContentProps = {
children: ReactNode;
className?: string;
transition?: Transition;
};
function CarouselContent({
children,
className,
transition,
}: CarouselContentProps) {
const { index, setIndex, setItemsCount, disableDrag } = useCarousel();
const [visibleItemsCount, setVisibleItemsCount] = useState(1);
const dragX = useMotionValue(0);
const containerRef = useRef<HTMLDivElement>(null);
const itemsLength = Children.count(children);
useEffect(() => {
if (!containerRef.current) {
return;
}
const options = {
root: containerRef.current,
threshold: 0.5,
};
const observer = new IntersectionObserver((entries) => {
const visibleCount = entries.filter(
(entry) => entry.isIntersecting
).length;
setVisibleItemsCount(visibleCount);
}, options);
const childNodes = containerRef.current.children;
Array.from(childNodes).forEach((child) => observer.observe(child));
return () => observer.disconnect();
}, [children, setItemsCount]);
useEffect(() => {
if (!itemsLength) {
return;
}
setItemsCount(itemsLength);
}, [itemsLength, setItemsCount]);
const onDragEnd = () => {
const x = dragX.get();
if (x <= -10 && index < itemsLength - 1) {
setIndex(index + 1);
} else if (x >= 10 && index > 0) {
setIndex(index - 1);
}
};
return (
<motion.div
drag={disableDrag ? false : 'x'}
dragConstraints={
disableDrag
? undefined
: {
left: 0,
right: 0,
}
}
dragMomentum={disableDrag ? undefined : false}
style={{
x: disableDrag ? undefined : dragX,
}}
animate={{
translateX: `-${index * (100 / visibleItemsCount)}%`,
}}
onDragEnd={disableDrag ? undefined : onDragEnd}
transition={
transition || {
damping: 18,
stiffness: 90,
type: 'spring',
duration: 0.2,
}
}
className={cn(
'flex items-center',
!disableDrag && 'cursor-grab active:cursor-grabbing',
className
)}
ref={containerRef}
>
{children}
</motion.div>
);
}
type CarouselItemProps = {
children: ReactNode;
className?: string;
};
function CarouselItem({ children, className }: CarouselItemProps) {
return (
<motion.div
className={cn(
'w-full min-w-0 shrink-0 grow-0 overflow-hidden',
className
)}
>
{children}
</motion.div>
);
}
export {
Carousel,
CarouselContent,
CarouselNavigation,
CarouselIndicator,
CarouselItem,
useCarousel,
};
Please add:
Component API
Carousel
Prop | Type | Default | Description |
---|---|---|---|
children | ReactNode | The content elements of the carousel. | |
className | string | Optional CSS class for styling the carousel container. | |
initialIndex | number | 0 | Initial index of the active carousel item. |
index | number | Controlled index of the active carousel item. | |
onIndexChange | (newIndex: number) => void | Callback function when the active index changes. | |
disableDrag | boolean | false | Whether dragging is disabled. |
CarouselNavigation
Prop | Type | Default | Description |
---|---|---|---|
className | string | Optional CSS class for styling the navigation container. | |
classNameButton | string | Optional CSS class for styling the navigation buttons. | |
alwaysShow | boolean | false | Whether navigation buttons are always visible. |
CarouselIndicator
Prop | Type | Default | Description |
---|---|---|---|
className | string | Optional CSS class for styling the indicator container. | |
classNameButton | string | Optional CSS class for styling the indicator buttons. |
CarouselContent
Prop | Type | Default | Description |
---|---|---|---|
children | ReactNode | The sliding elements or items of the carousel. | |
className | string | Optional CSS class for additional styling. | |
transition | Transition | Optional motion transition for animating slides. |
CarouselItem
Prop | Type | Default | Description |
---|---|---|---|
children | ReactNode | The individual content item within the carousel. | |
className | string | Optional CSS class for styling the carousel item. |
useCarousel
Return | Type | Description |
---|---|---|
index | number | The current index of the active carousel item. |
setIndex | (newIndex: number) => void | Function to update the index to show a different carousel item. |
itemsCount | number | Total number of items in the carousel. |
setItemsCount | (newItemsCount: number) => void | Function to set the number of items in the carousel. |
This hook provides context values related to the carousel's state, allowing components within the carousel to adjust based on the active item and total items.