Box Carousel
Box Carousel is a React and Tailwind CSS media component from Fancy Components, licensed MIT. Copy it and paste it into your project.
Fancy Components
MIT
media
What it is
Box Carousel is a React and Tailwind CSS media component from Fancy Components, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i motion
Licence
This component comes from Fancy Components and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Box Carousel
"use client"
import React, {
forwardRef,
memo,
ReactNode,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react"
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
ValueAnimationOptions,
} from "motion/react"
import { cn } from "@/lib/utils"
interface CarouselItem {
/**
* Unique identifier for the carousel item
*/
id: string
/**
* The type of media: "image" or "video"
*/
type: "image" | "video"
/**
* Source URL for the image or video
*/
src: string
/**
* (Optional) Alternative text for images
*/
alt?: string
/**
* (Optional) Poster image for videos (displayed before playback)
*/
poster?: string
}
/**
* Props for a single face of the cube in the BoxCarousel.
*/
interface FaceProps {
/**
* The CSS transform string to position and rotate the face in 3D space.
*/
transform: string
/**
* Optional additional CSS class names for the face.
*/
className?: string
/**
* Optional React children to render inside the face.
*/
children?: ReactNode
/**
* Optional inline styles for the face.
*/
style?: React.CSSProperties
/**
* If true, enables debug mode (e.g., shows backface and opacity).
*/
debug?: boolean
}
const CubeFace = memo(
({ transform, className, children, style, debug }: FaceProps) => (
<div
className={cn(
"absolute overflow-hidden",
debug && "backface-visible opacity-50",
className
)}
style={{ transform, ...style }}
>
{children}
</div>
)
)
CubeFace.displayName = "CubeFace"
const MediaRenderer = memo(
({
item,
className,
debug = false,
}: {
item: CarouselItem
className?: string
debug?: boolean
}) => {
if (!debug) {
if (item.type === "video") {
return (
<video
src={item.src}
poster={item.poster}
className={cn("w-full h-full object-cover", className)}
muted
loop
autoPlay
/>
)
}
return (
<img
src={item.src}
alt={item.alt || ""}
draggable={false}
className={cn("w-full h-full object-cover", className)}
/>
)
}
return (
<div
className={cn(
"w-full h-full flex items-center justify-center border text-2xl",
className
)}
>
{item.id}
</div>
)
}
)
MediaRenderer.displayName = "MediaRenderer"
export interface BoxCarouselRef {
/**
* Advance to the next item in the carousel.
*/
next: () => void
/**
* Go back to the previous item in the carousel.
*/
prev: () => void
/**
* Get the index of the currently visible item.
*/
getCurrentItemIndex: () => number
}
type RotationDirection = "top" | "bottom" | "left" | "right"
interface SpringConfig {
stiffness?: number
damping?: number
mass?: number
}
/**
* Props for the BoxCarousel component
*/
interface BoxCarouselProps extends React.HTMLProps<HTMLDivElement> {
/**
* Array of items to display in the carousel
*/
items: CarouselItem[]
/**
* Width of the carousel in pixels
*/
width: number
/**
* Height of the carousel in pixels
*/
height: number
/**
* Additional CSS classes for the container
*/
className?: string
/**
* Enable debug mode (shows extra info/overlays)
*/
debug?: boolean
/**
* Perspective value for 3D effect (in px)
* @default 600
*/
perspective?: number
/**
* The axis and direction of rotation
* @default "vertical"
* "top" | "bottom" | "left" | "right"
*/
direction?: RotationDirection
/**
* Transition configuration for rotation animation
* @default { duration: 1.25, ease: [0.953, 0.001, 0.019, 0.995] }
*/
transition?: ValueAnimationOptions
/**
* Transition configuration for snapping after drag
* @default { type: "spring", damping: 30, stiffness: 200 }
*/
snapTransition?: ValueAnimationOptions
/**
* Spring physics config for drag interaction
* @default { stiffness: 200, damping: 30 }
*/
dragSpring?: SpringConfig
/**
* Enable auto-play mode
* @default false
*/
autoPlay?: boolean
/**
* Interval (ms) between auto-play transitions
* @default 3000
*/
autoPlayInterval?: number
/**
* Callback when the current item index changes
*/
onIndexChange?: (index: number) => void
/**
* Enable drag interaction
* @default true
*/
enableDrag?: boolean
/**
* Sensitivity of drag (higher = more rotation per pixel)
* @default 0.5
*/
dragSensitivity?: number
}
const BoxCarousel = forwardRef<BoxCarouselRef, BoxCarouselProps>(
(
{
items,
width,
height,
className,
perspective = 600,
debug = false,
direction = "left",
transition = { duration: 1.25, ease: [0.953, 0.001, 0.019, 0.995] },
snapTransition = { type: "spring", damping: 30, stiffness: 200 },
dragSpring = { stiffness: 200, damping: 30 },
autoPlay = false,
autoPlayInterval = 3000,
onIndexChange,
enableDrag = true,
dragSensitivity = 0.5,
...props
},
ref
) => {
const [currentItemIndex, setCurrentItemIndex] = useState(0)
const [currentFrontFaceIndex, setCurrentFrontFaceIndex] = useState(1)
const prefersReducedMotion = useReducedMotion()
const _transition = prefersReducedMotion ? { duration: 0 } : transition
// 0 ⇢ will be shown if the user presses "prev"
const [prevIndex, setPrevIndex] = useState(items.length - 1)
// 1 ⇢ item that is currently visible
const [currentIndex, setCurrentIndex] = useState(0)
// 2 ⇢ will be shown on the next "next"
const [nextIndex, setNextIndex] = useState(1)
// 3 ⇢ two steps ahead (the face that is at the back right now)
const [afterNextIndex, setAfterNextIndex] = useState(2)
const [currentRotation, setCurrentRotation] = useState(0)
const rotationCount = useRef(1)
const isRotating = useRef(false)
const pendingIndexChange = useRef<number | null>(null)
const isDragging = useRef(false)
const startPosition = useRef({ x: 0, y: 0 })
const startRotation = useRef(0)
const baseRotateX = useMotionValue(0)
const baseRotateY = useMotionValue(0)
// Use springs for smoother animation during drag
const springRotateX = useSpring(baseRotateX, dragSpring)
const springRotateY = useSpring(baseRotateY, dragSpring)
const handleAnimationComplete = useCallback(
(triggeredBy: string) => {
if (isRotating.current && pendingIndexChange.current !== null) {
isRotating.current = false
let newFrontFaceIndex: number
let currentBackFaceIndex: number
if (triggeredBy === "next") {
newFrontFaceIndex = (currentFrontFaceIndex + 1) % 4
currentBackFaceIndex = (newFrontFaceIndex + 2) % 4
} else {
newFrontFaceIndex = (currentFrontFaceIndex - 1 + 4) % 4
currentBackFaceIndex = (newFrontFaceIndex + 3) % 4
}
setCurrentItemIndex(pendingIndexChange.current)
onIndexChange?.(pendingIndexChange.current)
const indexOffset = triggeredBy === "next" ? 2 : -1
if (currentBackFaceIndex === 0) {
setPrevIndex(
(pendingIndexChange.current + indexOffset + items.length) %
items.length
)
} else if (currentBackFaceIndex === 1) {
setCurrentIndex(
(pendingIndexChange.current + indexOffset + items.length) %
items.length
)
} else if (currentBackFaceIndex === 2) {
setNextIndex(
(pendingIndexChange.current + indexOffset + items.length) %
items.length
)
} else if (currentBackFaceIndex === 3) {
setAfterNextIndex(
(pendingIndexChange.current + indexOffset + items.length) %
items.length
)
}
pendingIndexChange.current = null
rotationCount.current++
setCurrentFrontFaceIndex(newFrontFaceIndex)
}
},
[currentFrontFaceIndex, items.length, onIndexChange]
)
// Drag functionality - using direct event handlers like css-box
const handleDragStart = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
if (!enableDrag || isRotating.current) return
isDragging.current = true
const point = "touches" in e ? e.touches[0] : e
startPosition.current = { x: point.clientX, y: point.clientY }
startRotation.current = currentRotation
// Prevent default to avoid text selection
e.preventDefault()
},
[enableDrag, currentRotation]
)
const handleDragMove = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!isDragging.current || isRotating.current) return
const point = "touches" in e ? e.touches[0] : e
const deltaX = point.clientX - startPosition.current.x
const deltaY = point.clientY - startPosition.current.y
const isVertical = direction === "top" || direction === "bottom"
const delta = isVertical ? deltaY : deltaX
const rotationDelta = (delta * dragSensitivity) / 2
let newRotation = startRotation.current
if (direction === "top" || direction === "right") {
newRotation += rotationDelta
} else {
newRotation -= rotationDelta
}
// Constrain rotation to ±120 degrees from start position. Otherwise the index recalculation will be off. TBD - find a better solution
const minRotation = startRotation.current - 120
const maxRotation = startRotation.current + 120
newRotation = Math.max(minRotation, Math.min(maxRotation, newRotation))
// Apply the rotation immediately during drag
if (isVertical) {
baseRotateX.set(newRotation)
} else {
baseRotateY.set(newRotation)
}
},
[enableDrag, direction, dragSensitivity]
)
const handleDragEnd = useCallback(() => {
if (!isDragging.current) return
isDragging.current = false
const isVertical = direction === "top" || direction === "bottom"
const currentValue = isVertical ? baseRotateX.get() : baseRotateY.get()
// Calculate the nearest quarter rotation (90-degree increment)
const quarterRotations = Math.round(currentValue / 90)
const snappedRotation = quarterRotations * 90
// Calculate how many steps we've moved from the original position
const rotationDifference = snappedRotation - currentRotation
const steps = Math.round(rotationDifference / 90)
if (steps !== 0) {
isRotating.current = true
// Calculate new item index
let newItemIndex = currentItemIndex
for (let i = 0; i < Math.abs(steps); i++) {
if (steps > 0) {
newItemIndex = (newItemIndex + 1) % items.length
} else {
newItemIndex =
newItemIndex === 0 ? items.length - 1 : newItemIndex - 1
}
}
pendingIndexChange.current = newItemIndex
// Animate to the snapped position
const targetMotionValue = isVertical ? baseRotateX : baseRotateY
animate(targetMotionValue, snappedRotation, {
...snapTransition,
onComplete: () => {
handleAnimationComplete(steps > 0 ? "next" : "prev")
setCurrentRotation(snappedRotation)
},
})
} else {
// Snap back to current position
const targetMotionValue = isVertical ? baseRotateX : baseRotateY
animate(targetMotionValue, currentRotation, snapTransition)
}
}, [
direction,
baseRotateX,
baseRotateY,
currentRotation,
currentItemIndex,
items.length,
transition,
handleAnimationComplete,
])
// Set up global event listeners for drag
useEffect(() => {
if (enableDrag) {
window.addEventListener("mousemove", handleDragMove)
window.addEventListener("mouseup", handleDragEnd)
window.addEventListener("touchmove", handleDragMove)
window.addEventListener("touchend", handleDragEnd)
return () => {
window.removeEventListener("mousemove", handleDragMove)
window.removeEventListener("mouseup", handleDragEnd)
window.removeEventListener("touchmove", handleDragMove)
window.removeEventListener("touchend", handleDragEnd)
}
}
}, [enableDrag, handleDragMove, handleDragEnd])
const next = useCallback(() => {
if (items.length === 0 || isRotating.current) return
isRotating.current = true
const newIndex = (currentItemIndex + 1) % items.length
pendingIndexChange.current = newIndex
if (direction === "top") {
animate(baseRotateX, currentRotation + 90, {
..._transition,
onComplete: () => {
handleAnimationComplete("next")
setCurrentRotation(currentRotation + 90)
},
})
} else if (direction === "bottom") {
animate(baseRotateX, currentRotation - 90, {
..._transition,
onComplete: () => {
handleAnimationComplete("next")
setCurrentRotation(currentRotation - 90)
},
})
} else if (direction === "left") {
animate(baseRotateY, currentRotation - 90, {
..._transition,
onComplete: () => {
handleAnimationComplete("next")
setCurrentRotation(currentRotation - 90)
},
})
} else if (direction === "right") {
animate(baseRotateY, currentRotation + 90, {
..._transition,
onComplete: () => {
handleAnimationComplete("next")
setCurrentRotation(currentRotation + 90)
},
})
}
}, [items.length, direction, transition, currentRotation])
const prev = useCallback(() => {
if (items.length === 0 || isRotating.current) return
isRotating.current = true
const newIndex =
currentItemIndex === 0 ? items.length - 1 : currentItemIndex - 1
pendingIndexChange.current = newIndex
if (direction === "top") {
animate(baseRotateX, currentRotation - 90, {
..._transition,
onComplete: () => {
handleAnimationComplete("prev")
setCurrentRotation(currentRotation - 90)
},
})
} else if (direction === "bottom") {
animate(baseRotateX, currentRotation + 90, {
..._transition,
onComplete: () => {
handleAnimationComplete("prev")
setCurrentRotation(currentRotation + 90)
},
})
} else if (direction === "left") {
animate(baseRotateY, currentRotation + 90, {
..._transition,
onComplete: () => {
handleAnimationComplete("prev")
setCurrentRotation(currentRotation + 90)
},
})
} else if (direction === "right") {
animate(baseRotateY, currentRotation - 90, {
..._transition,
onComplete: () => {
handleAnimationComplete("prev")
setCurrentRotation(currentRotation - 90)
},
})
}
}, [items.length, direction, transition])
useImperativeHandle(
ref,
() => ({
next,
prev,
getCurrentItemIndex: () => currentItemIndex,
}),
[next, prev, currentItemIndex]
)
const depth = useMemo(
() => (direction === "top" || direction === "bottom" ? height : width),
[direction, width, height]
)
const transform = useTransform(
isDragging.current
? [springRotateX, springRotateY]
: [baseRotateX, baseRotateY],
([x, y]) =>
`translateZ(-${depth / 2}px) rotateX(${x}deg) rotateY(${y}deg)`
)
// Determine face transforms based on the desired rotation axis
const faceTransforms = (() => {
switch (direction) {
case "left":
return [
// left, front, right, back (rotation around Y-axis)
`rotateY(-90deg) translateZ(${width / 2}px)`,
`rotateY(0deg) translateZ(${depth / 2}px)`,
`rotateY(90deg) translateZ(${width / 2}px)`,
`rotateY(180deg) translateZ(${depth / 2}px)`,
]
case "top":
return [
// top, front, bottom, back (rotation around X-axis)
`rotateX(90deg) translateZ(${height / 2}px)`,
`rotateY(0deg) translateZ(${depth / 2}px)`,
`rotateX(-90deg) translateZ(${height / 2}px)`,
`rotateY(180deg) translateZ(${depth / 2}px) rotateZ(180deg)`,
]
case "right":
return [
// right, front, left, back (rotation around Y-axis)
`rotateY(90deg) translateZ(${width / 2}px)`,
`rotateY(0deg) translateZ(${depth / 2}px)`,
`rotateY(-90deg) translateZ(${width / 2}px)`,
`rotateY(180deg) translateZ(${depth / 2}px)`,
]
case "bottom":
return [
// bottom, front, top, back (rotation around X-axis)
`rotateX(-90deg) translateZ(${height / 2}px)`,
`rotateY(0deg) translateZ(${depth / 2}px)`,
`rotateX(90deg) translateZ(${height / 2}px)`,
`rotateY(180deg) translateZ(${depth / 2}px) rotateZ(180deg)`,
]
default:
return [
// left, front, right, back (rotation around Y-axis)
`rotateY(-90deg) translateZ(${width / 2}px)`,
`rotateY(0deg) translateZ(${depth / 2}px)`,
`rotateY(90deg) translateZ(${width / 2}px)`,
`rotateY(180deg) translateZ(${depth / 2}px)`,
]
}
})()
// Auto play functionality
useEffect(() => {
if (autoPlay && items.length > 0) {
const interval = setInterval(next, autoPlayInterval)
return () => clearInterval(interval)
}
}, [autoPlay, items.length, next, autoPlayInterval])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isRotating.current) return
switch (e.key) {
case "ArrowLeft":
e.preventDefault()
if (direction === "left" || direction === "right") {
prev()
}
break
case "ArrowRight":
e.preventDefault()
if (direction === "left" || direction === "right") {
next()
}
break
case "ArrowUp":
e.preventDefault()
if (direction === "top" || direction === "bottom") {
prev()
}
break
case "ArrowDown":
e.preventDefault()
if (direction === "top" || direction === "bottom") {
next()
}
break
default:
break
}
},
[direction, next, prev, items.length]
)
return (
<div
className={cn("relative focus:outline-0", enableDrag && "cursor-move", className)}
style={{
width,
height,
perspective: `${perspective}px`,
}}
onKeyDown={handleKeyDown}
tabIndex={0}
aria-label={`3D carousel with ${items.length} items`}
aria-describedby="carousel-instructions"
aria-live="polite"
aria-atomic="true"
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
{...props}
>
<div className="sr-only" aria-live="assertive">
Showing item {currentItemIndex + 1} of {items.length}:{" "}
{items[currentItemIndex]?.alt || `Item ${currentItemIndex + 1}`}
</div>
<motion.div
className="relative w-full h-full [transform-style:preserve-3d]"
style={{
transform: transform,
}}
>
{/* First face */}
<CubeFace
transform={faceTransforms[0]}
style={
debug
? { width, height, backgroundColor: "#ff9999" }
: { width, height }
}
debug={debug}
>
<MediaRenderer item={items[prevIndex]} debug={debug} />
</CubeFace>
{/* Second face */}
<CubeFace
transform={faceTransforms[1]}
style={
debug
? { width, height, backgroundColor: "#99ff99" }
: { width, height }
}
debug={debug}
>
<MediaRenderer item={items[currentIndex]} debug={debug} />
</CubeFace>
{/* Third face */}
<CubeFace
transform={faceTransforms[2]}
style={
debug
? { width, height, backgroundColor: "#9999ff" }
: { width, height }
}
debug={debug}
>
<MediaRenderer item={items[nextIndex]} debug={debug} />
</CubeFace>
{/* Fourth face */}
<CubeFace
transform={faceTransforms[3]}
style={
debug
? { width, height, backgroundColor: "#ffff99" }
: { width, height }
}
debug={debug}
>
<MediaRenderer item={items[afterNextIndex]} debug={debug} />
</CubeFace>
</motion.div>
</div>
)
}
)
BoxCarousel.displayName = "BoxCarousel"
export default BoxCarousel
export type { CarouselItem, RotationDirection, SpringConfig }