Animation
3D Carousel
Create a 3D carousel with Framer Motion
Preview
Code
"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 = [
"nature",
"mountain",
"beach",
"city",
"sky",
"forest",
"desert",
"ocean",
"lake",
"river",
"waterfall",
"cave",
"volcano",
"island",
"canyon",
];
const duration = 0.15;
const transition = { duration, ease: [0.32, 0.72, 0, 1], filter: "blur(4px)" };
const transitionOverlay = { duration: 0.5, ease: [0.32, 0.72, 0, 1] };
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",
stiffness: 500,
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)`;
export const ThreeDCarousel = () => {
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/10 flex items-center justify-center z-50 m-5 md:m-36 lg:mx-[19rem] rounded-3xl h-fit"
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],
}} // 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>
);
};
Usage
import { ThreeDCarousel } from "@/components/library/animation/3DCarousel";
export default function Home() {
return <ThreeDCarousel />;
}
Edit on GitHub
Last updated on