3D Carousel
3D Carousel is a React and Tailwind CSS media component from Cult UI, licensed MIT. Copy it and paste it into your project.
Cult UI
MIT
media
What it is
3D Carousel is a React and Tailwind CSS media component from Cult UI, 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 Cult UI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
3D Carousel
"use client"
import { memo, useEffect, useLayoutEffect, useMemo, useState } from "react"
import {
AnimatePresence,
motion,
useAnimation,
useMotionValue,
useTransform,
} from "motion/react"
export const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect
type UseMediaQueryOptions = {
defaultValue?: boolean
initializeWithValue?: boolean
}
const IS_SERVER = typeof window === "undefined"
export function useMediaQuery(
query: string,
{
defaultValue = false,
initializeWithValue = true,
}: UseMediaQueryOptions = {}
): boolean {
const getMatches = (query: string): boolean => {
if (IS_SERVER) {
return defaultValue
}
return window.matchMedia(query).matches
}
const [matches, setMatches] = useState<boolean>(() => {
if (initializeWithValue) {
return getMatches(query)
}
return defaultValue
})
const handleChange = () => {
setMatches(getMatches(query))
}
useIsomorphicLayoutEffect(() => {
const matchMedia = window.matchMedia(query)
handleChange()
matchMedia.addEventListener("change", handleChange)
return () => {
matchMedia.removeEventListener("change", handleChange)
}
}, [query])
return matches
}
const keywords = [
"night",
"city",
"sky",
"sunset",
"sunrise",
"winter",
"skyscraper",
"building",
"cityscape",
"architecture",
"street",
"lights",
"downtown",
"bridge",
]
const duration = 0.15
const transition = {
duration,
ease: [0.32, 0.72, 0, 1] as const,
filter: "blur(4px)",
}
const transitionOverlay = { duration: 0.5, ease: [0.32, 0.72, 0, 1] as const }
const Carousel = memo(
({
handleClick,
controls,
cards,
isCarouselActive,
}: {
handleClick: (imgUrl: string, index: number) => void
controls: any
cards: string[]
isCarouselActive: boolean
}) => {
const isScreenSizeSm = useMediaQuery("(max-width: 640px)")
const cylinderWidth = isScreenSizeSm ? 1100 : 1800
const faceCount = cards.length
const faceWidth = cylinderWidth / faceCount
const radius = cylinderWidth / (2 * Math.PI)
const rotation = useMotionValue(0)
const transform = useTransform(
rotation,
(value) => `rotate3d(0, 1, 0, ${value}deg)`
)
return (
<div
className="flex h-full items-center justify-center bg-mauve-dark-2"
style={{
perspective: "1000px",
transformStyle: "preserve-3d",
willChange: "transform",
}}
>
<motion.div
drag={isCarouselActive ? "x" : false}
className="relative flex h-full origin-center cursor-grab justify-center active:cursor-grabbing"
style={{
transform,
rotateY: rotation,
width: cylinderWidth,
transformStyle: "preserve-3d",
}}
onDrag={(_, info) =>
isCarouselActive &&
rotation.set(rotation.get() + info.offset.x * 0.05)
}
onDragEnd={(_, info) =>
isCarouselActive &&
controls.start({
rotateY: rotation.get() + info.velocity.x * 0.05,
transition: {
type: "spring" as const,
stiffness: 100,
damping: 30,
mass: 0.1,
},
})
}
animate={controls}
>
{cards.map((imgUrl, i) => (
<motion.div
key={`key-${imgUrl}-${i}`}
className="absolute flex h-full origin-center items-center justify-center rounded-xl bg-mauve-dark-2 p-2"
style={{
width: `${faceWidth}px`,
transform: `rotateY(${
i * (360 / faceCount)
}deg) translateZ(${radius}px)`,
}}
onClick={() => handleClick(imgUrl, i)}
>
<motion.img
src={imgUrl}
alt={`keyword_${i} ${imgUrl}`}
layoutId={`img-${imgUrl}`}
className="pointer-events-none w-full rounded-xl object-cover aspect-square"
initial={{ filter: "blur(4px)" }}
layout="position"
animate={{ filter: "blur(0px)" }}
transition={transition}
/>
</motion.div>
))}
</motion.div>
</div>
)
}
)
const hiddenMask = `repeating-linear-gradient(to right, rgba(0,0,0,0) 0px, rgba(0,0,0,0) 30px, rgba(0,0,0,1) 30px, rgba(0,0,0,1) 30px)`
const visibleMask = `repeating-linear-gradient(to right, rgba(0,0,0,0) 0px, rgba(0,0,0,0) 0px, rgba(0,0,0,1) 0px, rgba(0,0,0,1) 30px)`
function ThreeDPhotoCarousel() {
const [activeImg, setActiveImg] = useState<string | null>(null)
const [isCarouselActive, setIsCarouselActive] = useState(true)
const controls = useAnimation()
const cards = useMemo(
() => keywords.map((keyword) => `https://picsum.photos/200/300?${keyword}`),
[]
)
useEffect(() => {
console.log("Cards loaded:", cards)
}, [cards])
const handleClick = (imgUrl: string) => {
setActiveImg(imgUrl)
setIsCarouselActive(false)
controls.stop()
}
const handleClose = () => {
setActiveImg(null)
setIsCarouselActive(true)
}
return (
<motion.div layout className="relative">
<AnimatePresence mode="sync">
{activeImg && (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
layoutId={`img-container-${activeImg}`}
layout="position"
onClick={handleClose}
className="fixed inset-0 bg-black bg-opacity-10 flex items-center justify-center z-50 m-5 md:m-36 lg:mx-[19rem] rounded-3xl"
style={{ willChange: "opacity" }}
transition={transitionOverlay}
>
<motion.img
layoutId={`img-${activeImg}`}
src={activeImg}
className="max-w-full max-h-full rounded-lg shadow-lg"
initial={{ scale: 0.5 }} // Start with a smaller scale
animate={{ scale: 1 }} // Animate to full scale
transition={{
delay: 0.5,
duration: 0.5,
ease: [0.25, 0.1, 0.25, 1] as const,
}} // Clean ease-out curve
style={{
willChange: "transform",
}}
/>
</motion.div>
)}
</AnimatePresence>
<div className="relative h-[500px] w-full overflow-hidden">
<Carousel
handleClick={handleClick}
controls={controls}
cards={cards}
isCarouselActive={isCarouselActive}
/>
</div>
</motion.div>
)
}
export default ThreeDPhotoCarousel