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:
AmirReza Jamali
2026-05-20 06:58:51 +03:30
parent 146285f514
commit 7dbe1cc41c
+434 -65
View File
@@ -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>
);
};