Box Carousel
A 3D carousel component that displays items in a rotating box/cube layout with drag support and smooth animations.
Installation
1pnpm dlx shadcn@latest add "https://fancycomponents.dev/r/box-carousel.json"
Usage
The Box Carousel creates a 3D rotating cube effect where each face displays a different item from your collection. You can navigate through items using mouse/touch drag, keyboard arrows, or control it via ref.
You need to pass an items array with at least 4 items, as well as the desired width and height of your items.
High-level example
1function MyCarousel() {2 return (3 <BoxCarousel4 items={items}5 width={400}6 height={300}7 direction="right"8 enableDrag={true}9 />10 )11}
Understanding the Component
The component constructs a 3D box using four faces positioned with CSS transforms. The order of the faces depends on the rotation direction, which will be important when we update the item indices. In the following snippet you can follow through the order when the rotation direction is left
. If you want to dive deeper into how these transforms work, check out the CSS Box documentation.
Face positioning
1const faceTransforms = (() => {2 switch (direction) {3 case "left":4 return [5 `rotateY(-90deg) translateZ(${width / 2}px)`, // left face6 `rotateY(0deg) translateZ(${depth / 2}px)`, // front face7 `rotateY(90deg) translateZ(${width / 2}px)`, // right face8 `rotateY(180deg) translateZ(${depth / 2}px)`, // back face9 ]10 // ... other directions11 }12})()
The depth
is calculated based on the rotation direction - for horizontal rotations (left/right), it uses the width, and for vertical rotations (top/bottom), it uses the height. This means all items are constrained to the same aspect ratio:
Depth
1const depth = useMemo(2 () => (direction === "top" || direction === "bottom" ? height : width),3 [direction, width, height]4)
Rotation
The component uses Motion's useMotionValue
for control over rotations. For each rotation, we just add or subtract 90 degrees:
Motion values
1//...23const baseRotateX = useMotionValue(0) // For vertical rotations4const baseRotateY = useMotionValue(0) // For horizontal rotations56//...78// Rotate to next face when direction is left9} else if (direction === "left") {10 animate(baseRotateY, currentRotation - 90, {11 ..._transition,12 onComplete: () => {13 handleAnimationComplete("next")14 setCurrentRotation(currentRotation - 90)15 },16 })17}18//...
Then, we just transform these motion values to a CSS transform and use it on the whole box container.
3D transform
1//...23const transform = useTransform(4 isDragging.current ? [springRotateX, springRotateY] : [baseRotateX, baseRotateY],5 ([x, y]) => `translateZ(-${depth / 2}px) rotateX(${x}deg) rotateY(${y}deg)`6)78//...910<motion.div11 className="relative w-full h-full [transform-style:preserve-3d]"12 style={{13 transform: transform,14 }}15>
Item Management
The component maintains four item indices to track which items are displayed on each face. The first face (at prevIndex
) is, by default, the last item in our items array. The second face is the current camera-facing item, which is the first item in our array. The third face is the next item in our array, and the fourth face (backward-facing face) is the next item after the next item in our array.
Item indices
1const [prevIndex, setPrevIndex] = useState(items.length - 1) // Face 02const [currentIndex, setCurrentIndex] = useState(0) // Face 1 (visible)3const [nextIndex, setNextIndex] = useState(1) // Face 24const [afterNextIndex, setAfterNextIndex] = useState(2) // Face 3
If our carousel only had 4 items, we could leave these indices as-is. However, with more than 4 items, we need to update the indices after each rotation so that the correct items are always displayed on each face—even after several rotations.
In practice, only the index for the backward-facing face needs to be updated after a rotation; the other three faces remain consistent. The function that handles this may look a bit tricky at first, but the logic is straightforward: after each rotation, we determine which face is now at the back and update its index to point to the next appropriate item in the array.
Update item indices
1const handleAnimationComplete = useCallback(2 (triggeredBy: string) => {3 if (isRotating.current && pendingIndexChange.current !== null) {4 isRotating.current = false56 let newFrontFaceIndex: number7 let currentBackFaceIndex: number89 if (triggeredBy === "next") {10 newFrontFaceIndex = (currentFrontFaceIndex + 1) % 411 currentBackFaceIndex = (newFrontFaceIndex + 2) % 412 } else {13 newFrontFaceIndex = (currentFrontFaceIndex - 1 + 4) % 414 currentBackFaceIndex = (newFrontFaceIndex + 3) % 415 }1617 setCurrentItemIndex(pendingIndexChange.current)18 onIndexChange?.(pendingIndexChange.current)1920 const indexOffset = triggeredBy === "next" ? 2 : -12122 if (currentBackFaceIndex === 0) {23 setPrevIndex(24 (pendingIndexChange.current + indexOffset + items.length) %25 items.length26 )27 } else if (currentBackFaceIndex === 1) {28 setCurrentIndex(29 (pendingIndexChange.current + indexOffset + items.length) %30 items.length31 )32 } else if (currentBackFaceIndex === 2) {33 setNextIndex(34 (pendingIndexChange.current + indexOffset + items.length) %35 items.length36 )37 } else if (currentBackFaceIndex === 3) {38 setAfterNextIndex(39 (pendingIndexChange.current + indexOffset + items.length) %40 items.length41 )42 }4344 pendingIndexChange.current = null45 rotationCount.current++4647 setCurrentFrontFaceIndex(newFrontFaceIndex)48 }49 },50[currentFrontFaceIndex, items.length, onIndexChange]51)
Drag Interaction
The component supports drag interaction. In the following function you can see that we're modifying the base rotation values based on the delta of the mouse/touch position:
Drag handling
1const handleDragMove = useCallback(2 (e: MouseEvent | TouchEvent) => {3 if (!isDragging.current || isRotating.current) return45 const point = "touches" in e ? e.touches[0] : e6 const deltaX = point.clientX - startPosition.current.x7 const deltaY = point.clientY - startPosition.current.y89 const isVertical = direction === "top" || direction === "bottom"10 const delta = isVertical ? deltaY : deltaX11 const rotationDelta = (delta * dragSensitivity) / 21213 let newRotation = startRotation.current1415 if (direction === "top" || direction === "right") {16 newRotation += rotationDelta17 } else {18 newRotation -= rotationDelta19 }2021 // Constrain rotation to +/-120 degrees from start position. Otherwise the index recalculation will be off. TBD - find a better solution22 const minRotation = startRotation.current - 12023 const maxRotation = startRotation.current + 12024 newRotation = Math.max(minRotation, Math.min(maxRotation, newRotation))2526 // Apply the rotation immediately during drag27 if (isVertical) {28 baseRotateX.set(newRotation)29 } else {30 baseRotateY.set(newRotation)31 }32 },33 [enableDrag, direction, dragSensitivity]34)
When the drag interaction is released, the carousel will snap back to the nearest 90-degree increment:
Drag snap
1const handleDragEnd = useCallback(() => {2 if (!isDragging.current) return34 isDragging.current = false56 const isVertical = direction === "top" || direction === "bottom"7 const currentValue = isVertical ? baseRotateX.get() : baseRotateY.get()89 // Calculate the nearest quarter rotation (90-degree increment)10 const quarterRotations = Math.round(currentValue / 90)11 const snappedRotation = quarterRotations * 901213 // Calculate how many steps we've moved from the original position14 const rotationDifference = snappedRotation - currentRotation15 const steps = Math.round(rotationDifference / 90)1617 if (steps !== 0) {18 isRotating.current = true1920 // Calculate new item index21 let newItemIndex = currentItemIndex22 for (let i = 0; i < Math.abs(steps); i++) {23 if (steps > 0) {24 newItemIndex = (newItemIndex + 1) % items.length25 } else {26 newItemIndex =27 newItemIndex === 0 ? items.length - 1 : newItemIndex - 128 }29 }3031 pendingIndexChange.current = newItemIndex3233 // Animate to the snapped position34 const targetMotionValue = isVertical ? baseRotateX : baseRotateY35 animate(targetMotionValue, snappedRotation, {36 ...snapTransition,37 onComplete: () => {38 handleAnimationComplete(steps > 0 ? "next" : "prev")39 setCurrentRotation(snappedRotation)40 },41 })42 } else {43 // Snap back to current position44 const targetMotionValue = isVertical ? baseRotateX : baseRotateY45 animate(targetMotionValue, currentRotation, snapTransition)46 }47}, [48 direction,49 baseRotateX,50 baseRotateY,51 currentRotation,52 currentItemIndex,53 items.length,54 transition,55 handleAnimationComplete,56])
You can customize the snap transition by passing in a custom value for snapTransition
prop. The default value is { type: "spring", damping: 30, stiffness: 200 }
.
You can also customize the drag sensitivity and spring physics by passing in custom values for dragSensitivity
and dragSpring
props. The default values are 0.5
and { stiffness: 200, damping: 30 }
respectively.
An important note here is that the drag rotation is constrained to a +/- 120 degree range for the sake of simplicity. Otherwise we would need to re-order the whole items array to keep the correct ordering of items after a huge rotation. Feel free to open a PR if you'd like to fix this :).
Auto-play Mode
You can enable automatic progression through items with the autoPlay
prop:
Keyboard Navigation
The component includes full keyboard support when the carousel is in focus:
- Arrow keys: Navigate based on rotation direction
- Left/Right arrows work for
left
/right
directions - Up/Down arrows work for
top
/bottom
directions
- Left/Right arrows work for
Mixed Media Support
Supports both images and videos with different handling:
Videos automatically play with muted
, loop
, and autoPlay
attributes. If you need more custom controls here, modify the MediaRenderer
component.
Imperative API
You can access carousel controls programmatically using a ref. This can be handy when you want to trigger a rotation via buttons, just like in the first demo on the page:
Ref usage
1function MyComponent() {2 const carouselRef = useRef<BoxCarouselRef>(null)34 const handleNext = () => {5 carouselRef.current?.next()6 }78 const handlePrev = () => {9 carouselRef.current?.prev()10 }1112 const getCurrentIndex = () => {13 return carouselRef.current?.getCurrentItemIndex() ?? 014 }1516 return (17 <>18 <BoxCarousel ref={carouselRef} items={items} width={400} height={300} />19 <button onClick={handleNext}>Next</button>20 <button onClick={handlePrev}>Previous</button>21 </>22 )23}
Reduced Motion Support
The component automatically respects user preferences for reduced motion by setting transition duration to 0 when prefers-reduced-motion
is detected. The drag interaction remains intact, though.
Credits
You can find the links for each artwork used in the demo here.
Resources
- Intro to CSS 3D transforms by David DeSandro
- CSS Box
- Flickity by MetaFizzy
Props
BoxCarousel Props
Prop | Type | Default | Description |
---|---|---|---|
items* | CarouselItem[] | - | Array of items to display in the carousel |
width* | number | - | Width of the carousel in pixels |
height* | number | - | Height of the carousel in pixels |
className | string | - | Additional CSS classes for the container |
direction | "top" | "bottom" | "left" | "right" | "left" | The axis and direction of rotation |
perspective | number | 600 | CSS perspective value for 3D effect depth |
debug | boolean | false | Enable debug mode to visualize cube faces |
transition | ValueAnimationOptions | { duration: 1.25, ease: [0.953, 0.001, 0.019, 0.995] } | Animation options for programmatic rotations |
snapTransition | ValueAnimationOptions | { type: "spring", damping: 30, stiffness: 200 } | Animation options for drag snap-back |
dragSpring | SpringConfig | { stiffness: 200, damping: 30 } | Spring physics configuration for drag interactions |
autoPlay | boolean | false | Enable automatic progression through items |
autoPlayInterval | number | 3000 | Interval in milliseconds for auto-play |
enableDrag | boolean | true | Enable drag interaction for navigation |
dragSensitivity | number | 0.5 | Sensitivity multiplier for drag movement |
onIndexChange | (index: number) => void | - | Callback fired when the active item changes |
BoxCarousel Ref Methods
Method | Type | Default | Description |
---|---|---|---|
goTo | (index: number) => void | - | Programmatically go to a specific item index |
next | () => void | - | Advance to the next item |
prev | () => void | - | Go to the previous item |
getCurrentItemIndex | () => number | - | Get the current active item index |