Carousel
The carousel was one of the initial components I started on. As I went through the journey of creating this design system, I found it was better for the items to have a react node typing so that the carousel items can take in either content or images depending on the developer’s preference. In future versions, I plan to have variations for the current version and a version that takes up the full width and height of the parent container.
Props & Styling
Props
Carousel — children?: ReactNode (composed of one or more CarouselItem elements).
CarouselItem
| Prop | Type | Default | Description |
|---|---|---|---|
item | ReactNode | — (required) | Content or image rendered inside the slide. |
id | string | — (required) | Stable identifier used to register the item with the parent Carousel. |
Styling
CarouselItems register themselves with the parent via context (registerItem/deregisterItem in a mount/unmount useEffect), so Carousel derives the total item count from whoever is actually rendered rather than a prop you have to keep in sync. A ResizeObserver on the track measures itemWidth once, and every item is rendered at that fixed width so the track can slide by simple multiples of it (x: -(activeIndex * itemWidth), 0.3s easeOut).
The non-active items are visually pushed back with scale: 0.95 and a blur(10px) filter (0.2s easeInOut) — this is what communicates "not the current slide" instead of a dimmed overlay or opacity change, and doubles as a subtle depth cue during the slide transition. The prev/next arrows are the shared Button component in its Icon variant, so they inherit the same hover/tap feedback as icon buttons elsewhere in the library.
Code
import Button from "@components/Button";
import { buttonVariant } from "@types";
import { ArrowLeft, ArrowRight } from "lucide-react";
import CarouselContent from "./CarouselContent";
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import React from "react";
import { CarouselContext } from "./CarouselContext";
interface CarouselProps {
children?: React.ReactNode
}
const Carousel = ({children}: CarouselProps) => {
const [activeIndex, setActiveIndex] = useState(0);
const [itemIds, setItemIds] = useState<string[]>([]);
const [itemWidth, setItemWidth] = useState(0)
const widthRef = useRef<HTMLDivElement>(null);
const handleLeftArrow = () => {
setActiveIndex(Math.max(activeIndex - 1, 0))
}
const handleRightArrow = () => {
setActiveIndex(Math.min(activeIndex + 1, totalItems - 1))
}
const totalItems = itemIds.length;
const registerItem = useCallback((newItem: string) => {
setItemIds(prev => {
if(!prev.includes(newItem)) {
return [...prev, newItem]
}
else {
return prev
}
})
},[]);
const deregisterItem = useCallback((chosenItem: string) => {
setItemIds(prev => {
return prev.filter((item) => item !== chosenItem)
})
}, [])
const contextValues = useMemo(() => {
return {
activeIndex,
setActiveIndex,
registerItem,
deregisterItem,
itemWidth,
itemIds,
}
}, [activeIndex, setActiveIndex, registerItem, deregisterItem, itemWidth, itemIds,])
useEffect(() => {
const observer = new ResizeObserver((entries) => {
const newWidth = entries[0].contentRect.width;
if (newWidth > 0 && newWidth < 5000) {
setItemWidth(newWidth);
}
})
if(widthRef.current){
observer.observe(widthRef.current)
}
return () => observer.disconnect()
},[])
return (
<CarouselContext value = {contextValues}>
<div className = "grid grid-cols-[auto_1fr_auto] gap-2 justify-center items-center w-full h-full relative overflow-hidden"
>
<Button
variant = {buttonVariant.Icon}
onClick={handleLeftArrow}
>
<ArrowLeft className = "w-4 h-4 text-gray-600"></ArrowLeft>
</Button>
<div ref = {widthRef} className = "h-full self-stretch overflow-hidden ">
<CarouselContent
>
{children}
</CarouselContent>
</div>
<Button
variant = {buttonVariant.Icon}
onClick={handleRightArrow}
>
<ArrowRight className = "w-4 h-4 text-gray-600"></ArrowRight>
</Button>
</div>
</CarouselContext>
)
}
export default Carousel