Docs
Feature Carousel
Feature Carousel
An animated carousel component for showcasing features with smooth transitions and interactive elements.
Feature 1
Feature 1 description
Installation
Copy and paste the following code into your project.
/*
! Add the following to your .globals.css
.animated-cards::before {
@apply pointer-events-none absolute select-none rounded-3xl opacity-0 transition-opacity duration-300 hover:opacity-100;
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;
}
*/
"use client"
import {
forwardRef,
useCallback,
useEffect,
useRef,
useState,
type MouseEvent,
} from "react"
import Image, { type StaticImageData } from "next/image"
import cult from "@/assets/cults.png"
import clsx from "clsx"
import {
AnimatePresence,
motion,
useMotionTemplate,
useMotionValue,
type MotionStyle,
type MotionValue,
type Variants,
} from "motion/react"
import Balancer from "react-wrap-balancer"
import { cn } from "@/lib/utils"
// Types
type WrapperStyle = MotionStyle & {
"--x": MotionValue<string>
"--y": MotionValue<string>
}
interface CardProps {
title: string
description: string
bgClass?: string
}
interface ImageSet {
step1dark1?: StaticImageData | string
step1dark2?: StaticImageData | string
step1light1: StaticImageData | string
step1light2: StaticImageData | string
step2dark1?: StaticImageData | string
step2dark2?: StaticImageData | string
step2light1: StaticImageData | string
step2light2: StaticImageData | string
step3dark?: StaticImageData | string
step3light: StaticImageData | string
step4light: StaticImageData | string
alt: string
}
interface FeatureCarouselProps extends CardProps {
step1img1Class?: string
step1img2Class?: string
step2img1Class?: string
step2img2Class?: string
step3imgClass?: string
step4imgClass?: string
image: ImageSet
}
interface StepImageProps {
src: StaticImageData | 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: "Feature 1",
description: "Feature 1 description ",
},
{
id: "2",
name: "Step 2",
title: "Feature 2",
description: "Feature 2 description",
},
{
id: "3",
name: "Step 3",
title: "Feature 3",
description: "Feature 3 description",
},
{
id: "4",
name: "Step 4",
title: "Feature 4",
description: "Feature 4 description",
},
] as const
/**
* Animation presets for reusable motion configurations.
* Each preset defines the initial, animate, and exit states,
* along with spring physics parameters for smooth transitions.
*/
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, // Higher value = more rigid spring
damping: 25, // Higher value = less oscillation
mass: 0.5, // Lower value = faster movement
},
},
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.
* Handles both automatic cycling and manual transitions between steps.
*/
function useNumberCycler(
totalSteps: number = TOTAL_STEPS,
interval: number = 3000
) {
const [currentNumber, setCurrentNumber] = useState(0)
const [isManualInteraction, setIsManualInteraction] = useState(false)
const timerRef = useRef<NodeJS.Timeout>()
// Setup timer function
const setupTimer = useCallback(() => {
console.log("Setting up timer")
// Clear any existing timer
if (timerRef.current) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(() => {
console.log("Timer triggered, advancing to next step")
setCurrentNumber((prev) => (prev + 1) % totalSteps)
setIsManualInteraction(false)
// Recursively setup next timer
setupTimer()
}, interval)
}, [interval, totalSteps])
// Handle manual increment
const increment = useCallback(() => {
console.log("Manual increment triggered")
setIsManualInteraction(true)
setCurrentNumber((prev) => (prev + 1) % totalSteps)
// Reset timer on manual interaction
setupTimer()
}, [totalSteps, setupTimer])
// Initial timer setup and cleanup
useEffect(() => {
console.log("Initial timer setup")
setupTimer()
return () => {
console.log("Cleaning up timer")
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
}, [setupTimer])
// Debug logging
useEffect(() => {
console.log("Current state:", {
currentNumber,
isManualInteraction,
hasTimer: !!timerRef.current,
})
}, [currentNumber, isManualInteraction])
return {
currentNumber,
increment,
isManualInteraction,
}
}
function useIsMobile() {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const userAgent = navigator.userAgent
const isSmall = window.matchMedia("(max-width: 768px)").matches
const isMobile = Boolean(
/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i.exec(
userAgent
)
)
const isDev = process.env.NODE_ENV !== "production"
if (isDev) setIsMobile(isSmall || isMobile)
setIsMobile(isSmall && isMobile)
}, [])
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 = 1200, height = 630, ...props },
ref
) => {
return (
<Image
ref={ref}
alt={alt}
className={className}
src={src}
width={width}
height={height}
style={{
position: "absolute",
userSelect: "none",
maxWidth: "unset",
...style,
}}
{...props}
/>
)
}
)
StepImage.displayName = "StepImage"
const MotionStepImage = motion(StepImage)
/**
* Wrapper component for StepImage that applies animation presets.
* Simplifies the application of complex animations through preset configurations.
*/
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.
* Uses motion values to create an interactive gradient that follows the cursor.
*/
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-[16px]"
onMouseMove={handleMouseMove}
style={
{
"--x": useMotionTemplate`${mouseX}px`,
"--y": useMotionTemplate`${mouseY}px`,
} as WrapperStyle
}
>
<div
className={clsx(
"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 dark:from-neutral-950/90 dark:to-neutral-800/90",
"md: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 dark:text-zinc-400">
<Balancer>{steps[step].description}</Balancer>
</p>
</motion.div>
</motion.div>
</AnimatePresence>
{mounted ? children : null}
</div>
</div>
</motion.div>
)
}
/**
* Progress indicator component that shows current step and completion status.
* Handles complex state transitions and animations for step indicators.
*/
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) => {
// Calculate step states for styling and animations
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-brand-400 text-white dark:bg-brand-400",
isCurrent &&
"bg-brand-300/80 text-neutral-400 dark:bg-neutral-500/50",
isFuture && "bg-brand-300/10 dark:bg-neutral-500/20"
)}
>
{isCompleted ? (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
<IconCheck className="h-3 w-3 stroke-white stroke-[3] text-white dark:stroke-black" />
</motion.div>
) : (
<span
className={cn(
"text-xs",
!isCurrent && "text-[#C6EA7E]"
)}
>
{stepIdx + 1}
</span>
)}
</motion.span>
<motion.span
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className={clsx(
"text-sm font-medium duration-300",
isCompleted && "text-muted-foreground",
isCurrent && "text-lime-300 dark:text-lime-500",
isFuture && "text-neutral-500"
)}
>
{step.name}
</motion.span>
</span>
</div>
</motion.li>
)
})}
</ol>
</nav>
)
}
const defaultClasses = {
step1img1:
"pointer-events-none w-[50%] border border-border-100/10 transition-all duration-500 dark:border-border-700/50 rounded-2xl",
step1img2:
"pointer-events-none w-[60%] border border-border-100/10 dark:border-border-700/50 transition-all duration-500 overflow-hidden rounded-2xl",
step2img1:
"pointer-events-none w-[50%] border border-border-100/10 transition-all duration-500 dark:border-border-700 rounded-2xl overflow-hidden",
step2img2:
"pointer-events-none w-[40%] border border-border-100/10 dark:border-border-700 transition-all duration-500 rounded-2xl overflow-hidden",
step3img:
"pointer-events-none w-[90%] border border-border-100/10 dark:border-border-700 rounded-2xl transition-all duration-500 overflow-hidden",
step4img:
"pointer-events-none w-[90%] border border-border-100/10 dark:border-border-700 rounded-2xl transition-all duration-500 overflow-hidden",
} as const
/**
* Main component that orchestrates the multi-step animation sequence.
* Manages state transitions, handles animation timing, and prevents
* animation conflicts through the isAnimating flag.
*/
export 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 [isAnimating, setIsAnimating] = useState(false)
const handleIncrement = () => {
if (isAnimating) return
setIsAnimating(true)
increment()
}
const handleAnimationComplete = () => {
setIsAnimating(false)
}
const renderStepContent = () => {
const content = () => {
switch (step) {
case 0:
/**
* Layout: Two images side by side
* - Left image (step1img1): 50% width, positioned left
* - Right image (step1img2): 60% width, positioned right
* Animation:
* - Left image slides in from left
* - Right image slides in from right with 0.1s delay
* - Both use spring animation for smooth motion
*/
return (
<motion.div
className="relative w-full h-full"
onAnimationComplete={handleAnimationComplete}
>
<AnimatedStepImage
alt={image.alt}
className={clsx(step1img1Class)}
src={image.step1light1}
preset="slideInLeft"
/>
<AnimatedStepImage
alt={image.alt}
className={clsx(step1img2Class)}
src={image.step1light2}
preset="slideInRight"
delay={0.1}
/>
</motion.div>
)
case 1:
/**
* Layout: Two images with overlapping composition
* - First image (step2img1): 50% width, positioned left
* - Second image (step2img2): 40% width, overlaps first image
* Animation:
* - Both images fade in and scale up from 95%
* - Second image has 0.1s delay for staggered effect
* - Uses spring physics for natural motion
*/
return (
<motion.div
className="relative w-full h-full"
onAnimationComplete={handleAnimationComplete}
>
<AnimatedStepImage
alt={image.alt}
className={clsx(step2img1Class, "rounded-2xl")}
src={image.step2light1}
preset="fadeInScale"
/>
<AnimatedStepImage
alt={image.alt}
className={clsx(step2img2Class, "rounded-2xl")}
src={image.step2light2}
preset="fadeInScale"
delay={0.1}
/>
</motion.div>
)
case 2:
/**
* Layout: Single centered image
* - Full width image (step3img): 90% width, centered
* Animation:
* - Fades in and scales up from 95%
* - Uses spring animation for smooth scaling
* - Triggers animation complete callback
*/
return (
<AnimatedStepImage
alt={image.alt}
className={clsx(step3imgClass, "rounded-2xl")}
src={image.step3light}
preset="fadeInScale"
onAnimationComplete={handleAnimationComplete}
/>
)
case 3:
/**
* Layout: Final showcase layout
* - Container: Centered, 60% width on desktop
* - Image (cult): 90% width, positioned slightly up
* Animation:
* - Container fades in and scales up
* - Image follows with 0.1s delay
* - Both use spring physics for natural motion
*/
return (
<motion.div
className={clsx(
"absolute left-2/4 top-1/3 flex w-[100%] -translate-x-1/2 -translate-y-[33%] flex-col gap-12 text-center text-2xl font-bold md:w-[60%]"
)}
{...ANIMATION_PRESETS.fadeInScale}
onAnimationComplete={handleAnimationComplete}
>
<AnimatedStepImage
alt={image.alt}
className="pointer-events-none top-[50%] w-[90%] overflow-hidden rounded-2xl border border-neutral-100/10 md:left-[35px] md:top-[30%] md:w-full dark:border-zinc-700"
src={cult}
preset="fadeInScale"
delay={0.1}
/>
</motion.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-[12rem] top-5 z-50 h-full w-full cursor-pointer md:left-0"
>
<Steps current={step} onChange={() => {}} steps={steps} />
</motion.div>
<motion.div
className="absolute right-0 top-0 z-50 h-full w-full cursor-pointer md:left-0"
onClick={handleIncrement}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
/>
</FeatureCard>
)
}
export FeatureCarousel
Update the import paths to match your project setup.
Usage
import { FeatureCarousel } from "@/components/animate/feature-card"
export default function Example() {
return (
<FeatureCarousel
title="Your Feature Title"
description="Your feature description"
image={{
step1light1: Image1,
step1light2: Image2,
step2light1: Image3,
step2light2: Image4,
step3light: Image5,
step4light: Image6,
alt: "Feature showcase",
}}
/>
)
}
Features
- Smooth transitions between steps with spring animations
- Interactive hover effects with gradient overlay
- Responsive design with mobile-first approach
- Customizable image positions and styles
- Auto-play with manual control
- Progress indicators with step tracking
Examples
Basic Usage
import { FeatureCarousel } from "@/components/animate/feature-card"
export default function BasicExample() {
return (
<FeatureCarousel
title="Feature Showcase"
description="Explore our amazing features"
image={{
step1light1: "/images/feature1.png",
step1light2: "/images/feature2.png",
step2light1: "/images/feature3.png",
step2light2: "/images/feature4.png",
step3light: "/images/feature5.png",
step4light: "/images/feature6.png",
alt: "Feature showcase",
}}
/>
)
}
Custom Styling
import { cn } from "@/lib/utils"
import { FeatureCarousel } from "@/components/animate/feature-card"
export default function StyledExample() {
return (
<FeatureCarousel
title="Custom Styled Features"
description="With custom positioning and effects"
step1img1Class={cn(
"pointer-events-none w-[50%] border border-stone-100/10",
"rounded-2xl left-[25%] top-[50%]",
"hover:scale-105 transition-transform"
)}
image={{
step1light1: "/images/feature1.png",
step1light2: "/images/feature2.png",
step2light1: "/images/feature3.png",
step2light2: "/images/feature4.png",
step3light: "/images/feature5.png",
step4light: "/images/feature6.png",
alt: "Feature showcase",
}}
bgClass="bg-gradient-to-tr from-blue-900/90 to-purple-800/90"
/>
)
}
Style Guide
The component uses Tailwind CSS for styling. Key style considerations:
- Use
pointer-events-none
for images to prevent interaction issues - Include
overflow-hidden
when using rounded corners - Add
transition-all
for smooth hover effects - Use responsive classes (
md:
,lg:
) for different layouts - Include dark mode variants for borders and backgrounds
Accessibility
The carousel includes:
- ARIA labels for navigation
- Keyboard navigation support
- Focus management
- Screen reader announcements for step changes
- Proper contrast ratios for text