Docs
Feature Carousel
An animated carousel component for showcasing features with smooth transitions and interactive elements.
Installation
Install dependencies
npm install framer-motion lucide-react
Run the following command
It will create a new file feature-carousel.tsx
inside the components/mage-ui/carousel
directory.
mkdir -p components/mage-ui/carousel && touch components/mage-ui/carousel/feature-carousel.tsx
Paste the code
Open the newly created file and paste the following code:
"use client"
import React, {
forwardRef,
useCallback,
useEffect,
useRef,
useState,
type MouseEvent,
} from "react"
import {
AnimatePresence,
motion,
useMotionTemplate,
useMotionValue,
type MotionStyle,
type MotionValue,
type Variants,
} from "framer-motion"
// Global styles
const globalStyles = `
.animated-cards::before {
pointer-events: none;
position: absolute;
user-select: none;
border-radius: 1.5rem;
opacity: 0;
transition-property: opacity;
transition-duration: 300ms;
background: radial-gradient(
1000px circle at var(--x) var(--y),
#c9ee80 0,
#eebbe2 10%,
#adc0ec 25%,
#c9ee80 35%,
rgba(255, 255, 255, 0) 50%,
transparent 80%
);
z-index: -1;
content: "";
inset: -1px;
}
.animated-cards:hover::before {
opacity: 1;
}
`
// Utility function
const cn = (...classes: (string | undefined | null | false)[]) => {
return classes.filter(Boolean).join(' ')
}
// Types
type WrapperStyle = MotionStyle & {
"--x": MotionValue<string>
"--y": MotionValue<string>
}
interface CardProps {
title: string
description: string
bgClass?: string
}
interface ImageSet {
step1dark1?: string
step1dark2?: string
step1light1: string
step1light2: string
step2dark1?: string
step2dark2?: string
step2light1: string
step2light2: string
step3dark?: string
step3light: string
step4light: string
alt: string
}
interface FeatureCarouselProps extends CardProps {
step1img1Class?: string
step1img2Class?: string
step2img1Class?: string
step2img2Class?: string
step3imgClass?: string
step4imgClass?: string
image: ImageSet
}
interface StepImageProps {
src: string
alt: string
className?: string
style?: React.CSSProperties
width?: number
height?: number
}
interface Step {
id: string
name: string
title: string
description: string
}
// Constants
const TOTAL_STEPS = 4
const steps = [
{
id: "1",
name: "Step 1",
title: "Beautiful Interfaces",
description: "Create stunning user interfaces with smooth animations and modern design patterns that captivate your users.",
},
{
id: "2",
name: "Step 2",
title: "Seamless Interactions",
description: "Build responsive and intuitive interactions that make your application feel alive and engaging.",
},
{
id: "3",
name: "Step 3",
title: "Performance Optimized",
description: "Enjoy lightning-fast performance with optimized animations and efficient rendering for the best user experience.",
},
{
id: "4",
name: "Step 4",
title: "Mobile Ready",
description: "Your components work perfectly across all devices with responsive design and touch-friendly interactions.",
},
] as const
// Sample images using placeholder service
const sampleImages = {
step1light1: "https://images.unsplash.com/photo-1551434678-e076c223a692?w=800&h=600&fit=crop&crop=entropy&cs=tinysrgb",
step1light2: "https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=800&h=600&fit=crop&crop=entropy&cs=tinysrgb",
step2light1: "https://images.unsplash.com/photo-1557804506-669a67965ba0?w=800&h=600&fit=crop&crop=entropy&cs=tinysrgb",
step2light2: "https://images.unsplash.com/photo-1555421689-491a97ff2040?w=800&h=600&fit=crop&crop=entropy&cs=tinysrgb",
step3light: "https://images.unsplash.com/photo-1581291518857-4e27b48ff24e?w=800&h=600&fit=crop&crop=entropy&cs=tinysrgb",
step4light: "https://images.unsplash.com/photo-1512758017271-d7b84c2113f1?w=800&h=600&fit=crop&crop=entropy&cs=tinysrgb",
alt: "Feature demonstration"
}
/**
* Animation presets for reusable motion configurations.
*/
const ANIMATION_PRESETS = {
fadeInScale: {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.95 },
transition: {
type: "spring",
stiffness: 300,
damping: 25,
mass: 0.5,
},
},
slideInRight: {
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 },
transition: {
type: "spring",
stiffness: 300,
damping: 25,
mass: 0.5,
},
},
slideInLeft: {
initial: { opacity: 0, x: -20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 20 },
transition: {
type: "spring",
stiffness: 300,
damping: 25,
mass: 0.5,
},
},
} as const
type AnimationPreset = keyof typeof ANIMATION_PRESETS
interface AnimatedStepImageProps extends StepImageProps {
preset?: AnimationPreset
delay?: number
onAnimationComplete?: () => void
}
/**
* Custom hook for managing cyclic transitions with auto-play functionality.
*/
function useNumberCycler(
totalSteps: number = TOTAL_STEPS,
interval: number = 4000
) {
const [currentNumber, setCurrentNumber] = useState(0)
const [isManualInteraction, setIsManualInteraction] = useState(false)
const timerRef = useRef<NodeJS.Timeout>()
const setupTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(() => {
setCurrentNumber((prev) => (prev + 1) % totalSteps)
setIsManualInteraction(false)
setupTimer()
}, interval)
}, [interval, totalSteps])
const increment = useCallback(() => {
setIsManualInteraction(true)
setCurrentNumber((prev) => (prev + 1) % totalSteps)
setupTimer()
}, [totalSteps, setupTimer])
useEffect(() => {
setupTimer()
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
}, [setupTimer])
return {
currentNumber,
increment,
isManualInteraction,
}
}
function useIsMobile() {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const checkMobile = () => {
const isSmall = window.matchMedia("(max-width: 768px)").matches
setIsMobile(isSmall)
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
return isMobile
}
// Components
function IconCheck({ className, ...props }: React.ComponentProps<"svg">) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
fill="currentColor"
className={cn("h-4 w-4", className)}
{...props}
>
<path d="m229.66 77.66-128 128a8 8 0 0 1-11.32 0l-56-56a8 8 0 0 1 11.32-11.32L96 188.69 218.34 66.34a8 8 0 0 1 11.32 11.32Z" />
</svg>
)
}
const stepVariants: Variants = {
inactive: {
scale: 0.8,
opacity: 0.5,
},
active: {
scale: 1,
opacity: 1,
},
}
const StepImage = forwardRef<
HTMLImageElement,
StepImageProps & { [key: string]: any }
>(
(
{ src, alt, className, style, width = 800, height = 600, ...props },
ref
) => {
return (
<img
ref={ref}
alt={alt}
className={className}
src={src}
width={width}
height={height}
style={{
position: "absolute",
userSelect: "none",
maxWidth: "unset",
objectFit: "cover",
...style,
}}
{...props}
/>
)
}
)
StepImage.displayName = "StepImage"
const MotionStepImage = motion(StepImage)
/**
* Wrapper component for StepImage that applies animation presets.
*/
const AnimatedStepImage = ({
preset = "fadeInScale",
delay = 0,
onAnimationComplete,
...props
}: AnimatedStepImageProps) => {
const presetConfig = ANIMATION_PRESETS[preset]
return (
<MotionStepImage
{...props}
{...presetConfig}
transition={{
...presetConfig.transition,
delay,
}}
onAnimationComplete={onAnimationComplete}
/>
)
}
/**
* Main card component that handles mouse tracking for gradient effect.
*/
function FeatureCard({
bgClass,
children,
step,
}: CardProps & {
children: React.ReactNode
step: number
}) {
const [mounted, setMounted] = useState(false)
const mouseX = useMotionValue(0)
const mouseY = useMotionValue(0)
const isMobile = useIsMobile()
function handleMouseMove({ currentTarget, clientX, clientY }: MouseEvent) {
if (isMobile) return
const { left, top } = currentTarget.getBoundingClientRect()
mouseX.set(clientX - left)
mouseY.set(clientY - top)
}
useEffect(() => {
setMounted(true)
}, [])
return (
<motion.div
className="animated-cards relative w-full rounded-2xl"
onMouseMove={handleMouseMove}
style={
{
"--x": useMotionTemplate`${mouseX}px`,
"--y": useMotionTemplate`${mouseY}px`,
} as WrapperStyle
}
>
<div
className={cn(
"group relative w-full overflow-hidden rounded-3xl border border-black/10 bg-gradient-to-b from-neutral-900/90 to-stone-800 transition duration-300",
"hover:border-transparent",
bgClass
)}
>
<div className="m-10 min-h-[450px] w-full">
<AnimatePresence mode="wait">
<motion.div
key={step}
className="flex w-4/6 flex-col gap-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.3,
ease: [0.23, 1, 0.32, 1],
}}
>
<motion.h2
className="text-xl font-bold tracking-tight text-white md:text-2xl"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{
delay: 0.1,
duration: 0.3,
ease: [0.23, 1, 0.32, 1],
}}
>
{steps[step].title}
</motion.h2>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{
delay: 0.2,
duration: 0.3,
ease: [0.23, 1, 0.32, 1],
}}
>
<p className="text-sm leading-5 text-neutral-300 sm:text-base sm:leading-5">
{steps[step].description}
</p>
</motion.div>
</motion.div>
</AnimatePresence>
{mounted ? children : null}
</div>
</div>
</motion.div>
)
}
/**
* Progress indicator component that shows current step and completion status.
*/
function Steps({
steps,
current,
onChange,
}: {
steps: readonly Step[]
current: number
onChange: (index: number) => void
}) {
return (
<nav aria-label="Progress" className="flex justify-center px-4">
<ol
className="flex w-full flex-wrap items-start justify-start gap-2 sm:justify-center md:w-10/12 md:divide-y-0"
role="list"
>
{steps.map((step, stepIdx) => {
const isCompleted = current > stepIdx
const isCurrent = current === stepIdx
const isFuture = !isCompleted && !isCurrent
return (
<motion.li
key={`${step.name}-${stepIdx}`}
initial="inactive"
animate={isCurrent ? "active" : "inactive"}
variants={stepVariants}
transition={{ duration: 0.3 }}
className={cn(
"relative z-50 rounded-full px-3 py-1 transition-all duration-300 ease-in-out md:flex",
isCompleted ? "bg-neutral-500/20" : "bg-neutral-500/10"
)}
>
<div
className={cn(
"group flex w-full cursor-pointer items-center focus:outline-none focus-visible:ring-2",
(isFuture || isCurrent) && "pointer-events-none"
)}
onClick={() => onChange(stepIdx)}
>
<span className="flex items-center gap-2 text-sm font-medium">
<motion.span
initial={false}
animate={{
scale: isCurrent ? 1.2 : 1,
}}
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded-full duration-300",
isCompleted && "bg-green-400 text-white",
isCurrent && "bg-yellow-300/80 text-neutral-800",
isFuture && "bg-neutral-300/10"
)}
>
{isCompleted ? (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
<IconCheck className="h-3 w-3 text-white" />
</motion.div>
) : (
<span
className={cn(
"text-xs",
isCurrent ? "text-neutral-800" : "text-lime-300"
)}
>
{stepIdx + 1}
</span>
)}
</motion.span>
<motion.span
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className={cn(
"text-sm font-medium duration-300",
isCompleted && "text-neutral-400",
isCurrent && "text-lime-300",
isFuture && "text-neutral-500"
)}
>
{step.name}
</motion.span>
</span>
</div>
</motion.li>
)
})}
</ol>
</nav>
)
}
const defaultClasses = {
step1img1:
"pointer-events-none w-[50%] border border-neutral-100/10 transition-all duration-500 rounded-2xl",
step1img2:
"pointer-events-none w-[60%] border border-neutral-100/10 transition-all duration-500 overflow-hidden rounded-2xl",
step2img1:
"pointer-events-none w-[50%] border border-neutral-100/10 transition-all duration-500 rounded-2xl overflow-hidden",
step2img2:
"pointer-events-none w-[40%] border border-neutral-100/10 transition-all duration-500 rounded-2xl overflow-hidden",
step3img:
"pointer-events-none w-[90%] border border-neutral-100/10 rounded-2xl transition-all duration-500 overflow-hidden",
step4img:
"pointer-events-none w-[90%] border border-neutral-100/10 rounded-2xl transition-all duration-500 overflow-hidden",
} as const
/**
* Main component that orchestrates the multi-step animation sequence.
*/
function FeatureCarousel({
image,
step1img1Class = defaultClasses.step1img1,
step1img2Class = defaultClasses.step1img2,
step2img1Class = defaultClasses.step2img1,
step2img2Class = defaultClasses.step2img2,
step3imgClass = defaultClasses.step3img,
step4imgClass = defaultClasses.step4img,
...props
}: FeatureCarouselProps) {
const { currentNumber: step, increment } = useNumberCycler()
const renderStepContent = () => {
const content = () => {
switch (step) {
case 0:
return (
<div className="relative w-full h-full">
<AnimatedStepImage
alt={image.alt}
className={cn(step1img1Class, "left-[5%] top-[30%]")}
src={image.step1light1}
preset="slideInLeft"
/>
<AnimatedStepImage
alt={image.alt}
className={cn(step1img2Class, "right-[5%] top-[25%]")}
src={image.step1light2}
preset="slideInRight"
delay={0.1}
/>
</div>
)
case 1:
return (
<div className="relative w-full h-full">
<AnimatedStepImage
alt={image.alt}
className={cn(step2img1Class, "left-[10%] top-[30%]")}
src={image.step2light1}
preset="fadeInScale"
/>
<AnimatedStepImage
alt={image.alt}
className={cn(step2img2Class, "right-[15%] top-[35%]")}
src={image.step2light2}
preset="fadeInScale"
delay={0.1}
/>
</div>
)
case 2:
return (
<div className="relative w-full h-full">
<AnimatedStepImage
alt={image.alt}
className={cn(step3imgClass, "left-[5%] top-[30%]")}
src={image.step3light}
preset="fadeInScale"
/>
</div>
)
case 3:
return (
<div className="relative w-full h-full">
<div className="absolute left-1/2 top-1/3 flex w-[90%] -translate-x-1/2 -translate-y-1/3 flex-col gap-12 text-center text-2xl font-bold">
<AnimatedStepImage
alt={image.alt}
className="pointer-events-none w-full overflow-hidden rounded-2xl border border-neutral-100/10"
src={image.step4light}
preset="fadeInScale"
delay={0.1}
/>
</div>
</div>
)
default:
return null
}
}
return (
<AnimatePresence mode="wait">
<motion.div
key={step}
{...ANIMATION_PRESETS.fadeInScale}
className="w-full h-full absolute"
>
{content()}
</motion.div>
</AnimatePresence>
)
}
return (
<FeatureCard {...props} step={step}>
{renderStepContent()}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="absolute left-0 top-5 z-50 h-full w-full cursor-pointer"
>
<Steps current={step} onChange={() => { }} steps={steps} />
</motion.div>
<motion.div
className="absolute right-0 top-0 z-50 h-full w-full cursor-pointer"
onClick={increment}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
/>
</FeatureCard>
)
}
// Demo component
function FeatureCarouselDemo() {
return (
<div className="w-full max-w-5xl mx-auto">
<div className="rounded-[34px] bg-neutral-700 p-2">
<div className="relative z-10 grid w-full gap-8 rounded-[28px] bg-neutral-950 p-2">
<FeatureCarousel
title="Interactive Feature Demo"
description="Showcase your features with smooth animations and transitions"
step1img1Class={cn(
"pointer-events-none w-[50%] border border-stone-100/10 transition-all duration-500",
"rounded-[24px] left-[5%] top-[30%]",
"group-hover:translate-y-2"
)}
step1img2Class={cn(
"pointer-events-none w-[60%] border border-stone-100/10 transition-all duration-500 overflow-hidden",
"rounded-2xl right-[5%] top-[25%]",
"group-hover:-translate-y-6"
)}
step2img1Class={cn(
"pointer-events-none w-[50%] rounded-t-[24px] overflow-hidden border border-stone-100/10 transition-all duration-500",
"left-[10%] top-[30%]",
"group-hover:translate-y-2"
)}
step2img2Class={cn(
"pointer-events-none w-[40%] rounded-t-[24px] border border-stone-100/10 transition-all duration-500 rounded-2xl overflow-hidden",
"right-[15%] top-[35%]",
"group-hover:-translate-y-6"
)}
step3imgClass={cn(
"pointer-events-none w-[90%] border border-stone-100/10 rounded-t-[24px] transition-all duration-500 overflow-hidden",
"left-[5%] top-[30%]"
)}
step4imgClass={cn(
"pointer-events-none w-[90%] border border-stone-100/10 rounded-t-[24px] transition-all duration-500 overflow-hidden",
"left-[5%] top-[30%]"
)}
image={sampleImages}
bgClass="bg-gradient-to-tr from-neutral-900/90 to-neutral-800/90"
/>
</div>
</div>
</div>
)
}
// Main page component
export default function FeatureCarouselPage() {
return (
<>
<style>{globalStyles}</style>
<div className="min-h-screen text-white p-8">
<div className="max-w-7xl mx-auto">
<div className="mb-16">
<FeatureCarouselDemo />
</div>
</div>
</div>
</>
)
}