Docs
Carousel
Box Carousel

Box Carousel

A 3D carousel component that displays items in a rotating box/cube layout with drag support and smooth animations.

Showing item 1 of 7: Blurry poster
Long exposure shot of a motorcyclist
Blurry poster
Abstract blurry figure
Long exposure photo of a person

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 <BoxCarousel
4 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 face
6 `rotateY(0deg) translateZ(${depth / 2}px)`, // front face
7 `rotateY(90deg) translateZ(${width / 2}px)`, // right face
8 `rotateY(180deg) translateZ(${depth / 2}px)`, // back face
9 ]
10 // ... other directions
11 }
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//...
2
3const baseRotateX = useMotionValue(0) // For vertical rotations
4const baseRotateY = useMotionValue(0) // For horizontal rotations
5
6//...
7
8// Rotate to next face when direction is left
9} 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//...
2
3const transform = useTransform(
4 isDragging.current ? [springRotateX, springRotateY] : [baseRotateX, baseRotateY],
5 ([x, y]) => `translateZ(-${depth / 2}px) rotateX(${x}deg) rotateY(${y}deg)`
6)
7
8//...
9
10<motion.div
11 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 0
2const [currentIndex, setCurrentIndex] = useState(0) // Face 1 (visible)
3const [nextIndex, setNextIndex] = useState(1) // Face 2
4const [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 = false
5
6 let newFrontFaceIndex: number
7 let currentBackFaceIndex: number
8
9 if (triggeredBy === "next") {
10 newFrontFaceIndex = (currentFrontFaceIndex + 1) % 4
11 currentBackFaceIndex = (newFrontFaceIndex + 2) % 4
12 } else {
13 newFrontFaceIndex = (currentFrontFaceIndex - 1 + 4) % 4
14 currentBackFaceIndex = (newFrontFaceIndex + 3) % 4
15 }
16
17 setCurrentItemIndex(pendingIndexChange.current)
18 onIndexChange?.(pendingIndexChange.current)
19
20 const indexOffset = triggeredBy === "next" ? 2 : -1
21
22 if (currentBackFaceIndex === 0) {
23 setPrevIndex(
24 (pendingIndexChange.current + indexOffset + items.length) %
25 items.length
26 )
27 } else if (currentBackFaceIndex === 1) {
28 setCurrentIndex(
29 (pendingIndexChange.current + indexOffset + items.length) %
30 items.length
31 )
32 } else if (currentBackFaceIndex === 2) {
33 setNextIndex(
34 (pendingIndexChange.current + indexOffset + items.length) %
35 items.length
36 )
37 } else if (currentBackFaceIndex === 3) {
38 setAfterNextIndex(
39 (pendingIndexChange.current + indexOffset + items.length) %
40 items.length
41 )
42 }
43
44 pendingIndexChange.current = null
45 rotationCount.current++
46
47 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) return
4
5 const point = "touches" in e ? e.touches[0] : e
6 const deltaX = point.clientX - startPosition.current.x
7 const deltaY = point.clientY - startPosition.current.y
8
9 const isVertical = direction === "top" || direction === "bottom"
10 const delta = isVertical ? deltaY : deltaX
11 const rotationDelta = (delta * dragSensitivity) / 2
12
13 let newRotation = startRotation.current
14
15 if (direction === "top" || direction === "right") {
16 newRotation += rotationDelta
17 } else {
18 newRotation -= rotationDelta
19 }
20
21 // Constrain rotation to +/-120 degrees from start position. Otherwise the index recalculation will be off. TBD - find a better solution
22 const minRotation = startRotation.current - 120
23 const maxRotation = startRotation.current + 120
24 newRotation = Math.max(minRotation, Math.min(maxRotation, newRotation))
25
26 // Apply the rotation immediately during drag
27 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) return
3
4 isDragging.current = false
5
6 const isVertical = direction === "top" || direction === "bottom"
7 const currentValue = isVertical ? baseRotateX.get() : baseRotateY.get()
8
9 // Calculate the nearest quarter rotation (90-degree increment)
10 const quarterRotations = Math.round(currentValue / 90)
11 const snappedRotation = quarterRotations * 90
12
13 // Calculate how many steps we've moved from the original position
14 const rotationDifference = snappedRotation - currentRotation
15 const steps = Math.round(rotationDifference / 90)
16
17 if (steps !== 0) {
18 isRotating.current = true
19
20 // Calculate new item index
21 let newItemIndex = currentItemIndex
22 for (let i = 0; i < Math.abs(steps); i++) {
23 if (steps > 0) {
24 newItemIndex = (newItemIndex + 1) % items.length
25 } else {
26 newItemIndex =
27 newItemIndex === 0 ? items.length - 1 : newItemIndex - 1
28 }
29 }
30
31 pendingIndexChange.current = newItemIndex
32
33 // Animate to the snapped position
34 const targetMotionValue = isVertical ? baseRotateX : baseRotateY
35 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 position
44 const targetMotionValue = isVertical ? baseRotateX : baseRotateY
45 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:

Showing item 1 of 7: Blurry poster
Long exposure shot of a motorcyclist
Blurry poster
Abstract blurry figure
Long exposure photo of a person

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

Mixed Media Support

Supports both images and videos with different handling:

Showing item 1 of 5: @portalsandpaths
@deconstructie
@david_wise
@portalsandpaths

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)
3
4 const handleNext = () => {
5 carouselRef.current?.next()
6 }
7
8 const handlePrev = () => {
9 carouselRef.current?.prev()
10 }
11
12 const getCurrentIndex = () => {
13 return carouselRef.current?.getCurrentItemIndex() ?? 0
14 }
15
16 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


Props


BoxCarousel Props

PropTypeDefaultDescription

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
classNamestring-Additional CSS classes for the container
direction"top" | "bottom" | "left" | "right""left"The axis and direction of rotation
perspectivenumber600CSS perspective value for 3D effect depth
debugbooleanfalseEnable debug mode to visualize cube faces
transitionValueAnimationOptions{ duration: 1.25, ease: [0.953, 0.001, 0.019, 0.995] }Animation options for programmatic rotations
snapTransitionValueAnimationOptions{ type: "spring", damping: 30, stiffness: 200 }Animation options for drag snap-back
dragSpringSpringConfig{ stiffness: 200, damping: 30 }Spring physics configuration for drag interactions
autoPlaybooleanfalseEnable automatic progression through items
autoPlayIntervalnumber3000Interval in milliseconds for auto-play
enableDragbooleantrueEnable drag interaction for navigation
dragSensitivitynumber0.5Sensitivity multiplier for drag movement
onIndexChange(index: number) => void-Callback fired when the active item changes

BoxCarousel Ref Methods

MethodTypeDefaultDescription
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