feat(stepper): enhance Stepper component with animations and improved state management
- Added GSAP animations for step transitions, rail fill, and button hover effects to enhance user experience. - Implemented useLayoutEffect for intro animations and content swaps when steps change. - Introduced refs for managing step indicators and content, improving performance and maintainability. - Updated button click handling to ensure smooth scrolling and step navigation.
This commit is contained in:
+434
-65
@@ -1,5 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon } from "@/assets/icons";
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import gsap from "gsap";
|
||||
import React, {
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface Step {
|
||||
title: string;
|
||||
description?: string;
|
||||
@@ -25,6 +35,160 @@ const Stepper: React.FC<StepperProps> = ({
|
||||
const currentStep =
|
||||
controlledStep !== undefined ? controlledStep : internalStep;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const railRef = useRef<HTMLDivElement>(null);
|
||||
const railFillRef = useRef<HTMLDivElement>(null);
|
||||
const stepRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const labelRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||
const checkRefs = useRef<Array<HTMLSpanElement | null>>([]);
|
||||
const numberRefs = useRef<Array<HTMLSpanElement | null>>([]);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const prevBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const nextBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const submitGlowRef = useRef<HTMLSpanElement>(null);
|
||||
const prevStepRef = useRef<number>(currentStep);
|
||||
|
||||
// Intro animation
|
||||
useLayoutEffect(() => {
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduced) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from(stepRefs.current.filter(Boolean), {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
rotate: -180,
|
||||
duration: 0.7,
|
||||
stagger: 0.08,
|
||||
ease: "back.out(2)",
|
||||
});
|
||||
gsap.from(labelRefs.current.filter(Boolean), {
|
||||
y: 12,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
stagger: 0.08,
|
||||
delay: 0.15,
|
||||
ease: "power3.out",
|
||||
});
|
||||
if (contentRef.current) {
|
||||
gsap.from(contentRef.current, {
|
||||
y: 24,
|
||||
opacity: 0,
|
||||
duration: 0.6,
|
||||
delay: 0.3,
|
||||
ease: "power3.out",
|
||||
});
|
||||
}
|
||||
}, rootRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
// Animate rail fill + active circle pulse when current step changes
|
||||
useLayoutEffect(() => {
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const total = steps.length - 1;
|
||||
const target = total > 0 ? (currentStep / total) * 100 : 0;
|
||||
if (railFillRef.current) {
|
||||
gsap.to(railFillRef.current, {
|
||||
width: `${target}%`,
|
||||
duration: reduced ? 0 : 0.8,
|
||||
ease: "power3.inOut",
|
||||
});
|
||||
}
|
||||
|
||||
// Pulse the active circle
|
||||
const active = stepRefs.current[currentStep];
|
||||
if (active && !reduced) {
|
||||
gsap.fromTo(
|
||||
active,
|
||||
{ scale: 1 },
|
||||
{
|
||||
scale: 1.18,
|
||||
duration: 0.25,
|
||||
yoyo: true,
|
||||
repeat: 1,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Animate newly checked step
|
||||
const prev = prevStepRef.current;
|
||||
if (currentStep > prev && !reduced) {
|
||||
const justCompleted = checkRefs.current[prev];
|
||||
const oldNumber = numberRefs.current[prev];
|
||||
if (justCompleted) {
|
||||
gsap.fromTo(
|
||||
justCompleted,
|
||||
{ scale: 0, rotate: -90, opacity: 0 },
|
||||
{
|
||||
scale: 1,
|
||||
rotate: 0,
|
||||
opacity: 1,
|
||||
duration: 0.5,
|
||||
ease: "back.out(2.4)",
|
||||
},
|
||||
);
|
||||
}
|
||||
if (oldNumber) {
|
||||
gsap.to(oldNumber, { opacity: 0, duration: 0.2 });
|
||||
}
|
||||
}
|
||||
prevStepRef.current = currentStep;
|
||||
}, [currentStep, steps.length]);
|
||||
|
||||
// Animate content swap when step changes
|
||||
const isFirstContentRender = useRef(true);
|
||||
useLayoutEffect(() => {
|
||||
if (isFirstContentRender.current) {
|
||||
isFirstContentRender.current = false;
|
||||
return;
|
||||
}
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduced || !contentRef.current) return;
|
||||
|
||||
gsap.fromTo(
|
||||
contentRef.current,
|
||||
{ y: 18, opacity: 0, filter: "blur(6px)" },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
duration: 0.55,
|
||||
ease: "power3.out",
|
||||
},
|
||||
);
|
||||
}, [currentStep]);
|
||||
|
||||
// Submit button glow loop on last step
|
||||
useEffect(() => {
|
||||
if (currentStep !== steps.length - 1 || !submitGlowRef.current) return;
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduced) return;
|
||||
|
||||
const tween = gsap.to(submitGlowRef.current, {
|
||||
opacity: 0.85,
|
||||
scale: 1.08,
|
||||
duration: 1.2,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: "sine.inOut",
|
||||
});
|
||||
return () => {
|
||||
tween.kill();
|
||||
};
|
||||
}, [currentStep, steps.length]);
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
if (index <= currentStep) {
|
||||
if (onStepChange) {
|
||||
@@ -35,12 +199,22 @@ const Stepper: React.FC<StepperProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonHover = (
|
||||
btn: HTMLButtonElement | null,
|
||||
enter: boolean,
|
||||
) => {
|
||||
if (!btn) return;
|
||||
gsap.to(btn, {
|
||||
scale: enter ? 1.04 : 1,
|
||||
y: enter ? -2 : 0,
|
||||
duration: 0.25,
|
||||
ease: "power2.out",
|
||||
});
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep === steps.length - 1) {
|
||||
// On last step, trigger submit
|
||||
if (onSubmit) {
|
||||
onSubmit();
|
||||
}
|
||||
if (onSubmit) onSubmit();
|
||||
} else if (currentStep < steps.length - 1) {
|
||||
const newStep = currentStep + 1;
|
||||
if (onStepChange) {
|
||||
@@ -49,7 +223,7 @@ const Stepper: React.FC<StepperProps> = ({
|
||||
setInternalStep(newStep);
|
||||
}
|
||||
}
|
||||
if (window) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.scroll({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
@@ -63,90 +237,285 @@ const Stepper: React.FC<StepperProps> = ({
|
||||
setInternalStep(newStep);
|
||||
}
|
||||
}
|
||||
if (window) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.scroll({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
const isLastStep = currentStep === steps.length - 1;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl p-8">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<div className="flex flex-1 flex-col items-center">
|
||||
<button
|
||||
onClick={() => handleStepClick(index)}
|
||||
disabled={index > currentStep}
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full font-semibold transition-all duration-200 ${
|
||||
index < currentStep
|
||||
? "cursor-pointer bg-primary text-white hover:bg-primary/90"
|
||||
: index === currentStep
|
||||
? "bg-primary text-white ring-4 ring-primary/20"
|
||||
: "cursor-not-allowed bg-gray-2 text-gray-5"
|
||||
}`}
|
||||
>
|
||||
{index < currentStep ? (
|
||||
<CheckIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<span>{index + 1}</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="mt-2 text-center">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
index <= currentStep ? "text-dark-2" : "text-gray-4"
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
{step.description && (
|
||||
<div className="mt-1 text-xs text-gray-5">
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="relative mx-auto w-full max-w-4xl overflow-hidden rounded-2xl p-8"
|
||||
>
|
||||
{/* Decorative background blobs */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -left-24 -top-24 h-64 w-64 rounded-full bg-primary/10 blur-3xl"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -bottom-32 -right-16 h-72 w-72 rounded-full bg-blue/10 blur-3xl"
|
||||
/>
|
||||
|
||||
{/* Step indicator row */}
|
||||
<div className="relative mb-10">
|
||||
{/* Rail (track) */}
|
||||
<div
|
||||
ref={railRef}
|
||||
className="absolute left-[6%] right-[6%] top-6 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-stroke/70 dark:bg-dark-3"
|
||||
>
|
||||
{/* Animated fill */}
|
||||
<div
|
||||
ref={railFillRef}
|
||||
className="relative h-full w-0 rounded-full bg-gradient-to-r from-primary via-blue to-primary bg-[length:200%_100%]"
|
||||
style={{
|
||||
backgroundPosition: "0% 50%",
|
||||
animation: "stepperShimmer 3s linear infinite",
|
||||
}}
|
||||
>
|
||||
<span className="absolute right-0 top-1/2 h-3 w-3 -translate-y-1/2 translate-x-1/2 rounded-full bg-primary shadow-[0_0_12px_rgba(91,113,255,0.9)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-start justify-between">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < currentStep;
|
||||
const isActive = index === currentStep;
|
||||
const isLocked = index > currentStep;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mx-4 -mt-12 h-1 flex-1 transition-all duration-300 ${
|
||||
index < currentStep ? "bg-primary" : "bg-stroke"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
key={index}
|
||||
className="relative flex flex-1 flex-col items-center"
|
||||
>
|
||||
<button
|
||||
ref={(el) => {
|
||||
stepRefs.current[index] = el;
|
||||
}}
|
||||
onClick={() => handleStepClick(index)}
|
||||
disabled={isLocked}
|
||||
className={`group relative flex h-12 w-12 items-center justify-center rounded-full text-base font-semibold transition-colors duration-300 ${
|
||||
isCompleted
|
||||
? "cursor-pointer bg-gradient-to-br from-primary to-blue text-white shadow-lg shadow-primary/30"
|
||||
: isActive
|
||||
? "bg-gradient-to-br from-primary to-blue text-white shadow-lg shadow-primary/40"
|
||||
: "cursor-not-allowed bg-gray-2 text-gray-5 dark:bg-dark-3 dark:text-gray-4"
|
||||
}`}
|
||||
aria-current={isActive ? "step" : undefined}
|
||||
aria-label={`Step ${index + 1}: ${step.title}`}
|
||||
>
|
||||
{/* Active ring */}
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 -m-1.5 rounded-full ring-2 ring-primary/40"
|
||||
style={{ animation: "stepperRing 2s ease-out infinite" }}
|
||||
/>
|
||||
)}
|
||||
{/* Inner shine */}
|
||||
{(isCompleted || isActive) && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 overflow-hidden rounded-full"
|
||||
>
|
||||
<span className="absolute -inset-1 block bg-gradient-to-tr from-white/0 via-white/40 to-white/0 opacity-60 mix-blend-overlay" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Number */}
|
||||
<span
|
||||
ref={(el) => {
|
||||
numberRefs.current[index] = el;
|
||||
}}
|
||||
className={`relative z-10 ${isCompleted ? "hidden" : "block"}`}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
{/* Check */}
|
||||
<span
|
||||
ref={(el) => {
|
||||
checkRefs.current[index] = el;
|
||||
}}
|
||||
className={`relative z-10 ${isCompleted ? "block" : "hidden"}`}
|
||||
>
|
||||
<CheckIcon className="h-6 w-6" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={(el) => {
|
||||
labelRefs.current[index] = el;
|
||||
}}
|
||||
className="mt-4 text-center"
|
||||
>
|
||||
<div
|
||||
className={`text-sm font-semibold transition-colors duration-300 ${
|
||||
isActive
|
||||
? "text-primary"
|
||||
: index <= currentStep
|
||||
? "text-dark-2 dark:text-white"
|
||||
: "text-gray-4"
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
{step.description && (
|
||||
<div className="mt-1 max-w-[140px] text-xs text-gray-5">
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>{steps[currentStep].content}</div>
|
||||
{/* Step content */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
key={currentStep}
|
||||
className="relative rounded-xl"
|
||||
>
|
||||
{steps[currentStep].content}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-between">
|
||||
{/* Nav buttons */}
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<button
|
||||
ref={prevBtnRef}
|
||||
onClick={prevStep}
|
||||
onMouseEnter={() => handleButtonHover(prevBtnRef.current, true)}
|
||||
onMouseLeave={() => handleButtonHover(prevBtnRef.current, false)}
|
||||
disabled={currentStep === 0}
|
||||
className={`rounded-lg px-6 py-2 font-medium transition-all ${
|
||||
className={`group relative inline-flex items-center gap-2 overflow-hidden rounded-xl px-6 py-2.5 text-sm font-semibold transition-all ${
|
||||
currentStep === 0
|
||||
? "cursor-not-allowed bg-gray-1 text-gray-4"
|
||||
: "bg-gray-2 text-gray-7 hover:bg-gray-3"
|
||||
? "cursor-not-allowed bg-gray-1 text-gray-4 dark:bg-dark-3"
|
||||
: "bg-gray-2 text-dark-2 hover:bg-gray-3 dark:bg-dark-3 dark:text-white"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 transition-transform group-hover:-translate-x-0.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
{/* Step counter */}
|
||||
<div className="hidden text-xs font-medium text-gray-5 sm:block">
|
||||
<span className="text-primary">{currentStep + 1}</span>
|
||||
<span className="mx-1">/</span>
|
||||
<span>{steps.length}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
ref={nextBtnRef}
|
||||
onClick={nextStep}
|
||||
onMouseEnter={() => handleButtonHover(nextBtnRef.current, true)}
|
||||
onMouseLeave={() => handleButtonHover(nextBtnRef.current, false)}
|
||||
disabled={isSubmitting}
|
||||
className={`rounded-lg px-6 py-2 font-medium transition-all ${
|
||||
className={`group relative inline-flex items-center gap-2 overflow-hidden rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all ${
|
||||
isSubmitting
|
||||
? "cursor-not-allowed bg-gray-1 text-gray-4"
|
||||
: "bg-primary text-white hover:bg-primary/90"
|
||||
? "cursor-not-allowed bg-gray-4"
|
||||
: "bg-gradient-to-r from-primary via-blue to-primary bg-[length:200%_100%] shadow-lg shadow-primary/30 hover:shadow-primary/50"
|
||||
}`}
|
||||
style={
|
||||
!isSubmitting
|
||||
? { animation: "stepperShimmer 3s linear infinite" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isSubmitting
|
||||
? "Submitting..."
|
||||
: currentStep === steps.length - 1
|
||||
? "Submit"
|
||||
: "Next"}
|
||||
{/* Glow halo for submit */}
|
||||
{isLastStep && !isSubmitting && (
|
||||
<span
|
||||
ref={submitGlowRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -z-10 rounded-xl bg-primary/40 opacity-40 blur-xl"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
d="M22 12a10 10 0 0 1-10 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isLastStep ? "Submit" : "Next"}
|
||||
<svg
|
||||
className="h-4 w-4 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d={isLastStep ? "M5 13l4 4L19 7" : "M9 5l7 7-7 7"}
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Local keyframes (background shimmer + ring pulse) */}
|
||||
<style jsx>{`
|
||||
@keyframes stepperShimmer {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 50%;
|
||||
}
|
||||
}
|
||||
@keyframes stepperRing {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.45);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.45);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user