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 { 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 {
|
export interface Step {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -25,6 +35,160 @@ const Stepper: React.FC<StepperProps> = ({
|
|||||||
const currentStep =
|
const currentStep =
|
||||||
controlledStep !== undefined ? controlledStep : internalStep;
|
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) => {
|
const handleStepClick = (index: number) => {
|
||||||
if (index <= currentStep) {
|
if (index <= currentStep) {
|
||||||
if (onStepChange) {
|
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 = () => {
|
const nextStep = () => {
|
||||||
if (currentStep === steps.length - 1) {
|
if (currentStep === steps.length - 1) {
|
||||||
// On last step, trigger submit
|
if (onSubmit) onSubmit();
|
||||||
if (onSubmit) {
|
|
||||||
onSubmit();
|
|
||||||
}
|
|
||||||
} else if (currentStep < steps.length - 1) {
|
} else if (currentStep < steps.length - 1) {
|
||||||
const newStep = currentStep + 1;
|
const newStep = currentStep + 1;
|
||||||
if (onStepChange) {
|
if (onStepChange) {
|
||||||
@@ -49,7 +223,7 @@ const Stepper: React.FC<StepperProps> = ({
|
|||||||
setInternalStep(newStep);
|
setInternalStep(newStep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (window) {
|
if (typeof window !== "undefined") {
|
||||||
window.scroll({ top: 0, behavior: "smooth" });
|
window.scroll({ top: 0, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -63,90 +237,285 @@ const Stepper: React.FC<StepperProps> = ({
|
|||||||
setInternalStep(newStep);
|
setInternalStep(newStep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (window) {
|
if (typeof window !== "undefined") {
|
||||||
window.scroll({ top: 0, behavior: "smooth" });
|
window.scroll({ top: 0, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isLastStep = currentStep === steps.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-4xl p-8">
|
<div
|
||||||
<div className="mb-8 flex items-center justify-between">
|
ref={rootRef}
|
||||||
{steps.map((step, index) => (
|
className="relative mx-auto w-full max-w-4xl overflow-hidden rounded-2xl p-8"
|
||||||
<React.Fragment key={index}>
|
>
|
||||||
<div className="flex flex-1 flex-col items-center">
|
{/* Decorative background blobs */}
|
||||||
<button
|
<div
|
||||||
onClick={() => handleStepClick(index)}
|
aria-hidden
|
||||||
disabled={index > currentStep}
|
className="pointer-events-none absolute -left-24 -top-24 h-64 w-64 rounded-full bg-primary/10 blur-3xl"
|
||||||
className={`flex h-12 w-12 items-center justify-center rounded-full font-semibold transition-all duration-200 ${
|
/>
|
||||||
index < currentStep
|
<div
|
||||||
? "cursor-pointer bg-primary text-white hover:bg-primary/90"
|
aria-hidden
|
||||||
: index === currentStep
|
className="pointer-events-none absolute -bottom-32 -right-16 h-72 w-72 rounded-full bg-blue/10 blur-3xl"
|
||||||
? "bg-primary text-white ring-4 ring-primary/20"
|
/>
|
||||||
: "cursor-not-allowed bg-gray-2 text-gray-5"
|
|
||||||
}`}
|
{/* Step indicator row */}
|
||||||
>
|
<div className="relative mb-10">
|
||||||
{index < currentStep ? (
|
{/* Rail (track) */}
|
||||||
<CheckIcon className="h-6 w-6" />
|
<div
|
||||||
) : (
|
ref={railRef}
|
||||||
<span>{index + 1}</span>
|
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"
|
||||||
)}
|
>
|
||||||
</button>
|
{/* Animated fill */}
|
||||||
<div className="mt-2 text-center">
|
<div
|
||||||
<div
|
ref={railFillRef}
|
||||||
className={`text-sm font-medium ${
|
className="relative h-full w-0 rounded-full bg-gradient-to-r from-primary via-blue to-primary bg-[length:200%_100%]"
|
||||||
index <= currentStep ? "text-dark-2" : "text-gray-4"
|
style={{
|
||||||
}`}
|
backgroundPosition: "0% 50%",
|
||||||
>
|
animation: "stepperShimmer 3s linear infinite",
|
||||||
{step.title}
|
}}
|
||||||
</div>
|
>
|
||||||
{step.description && (
|
<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 className="mt-1 text-xs text-gray-5">
|
</div>
|
||||||
{step.description}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
<div className="relative flex items-start justify-between">
|
||||||
</div>
|
{steps.map((step, index) => {
|
||||||
</div>
|
const isCompleted = index < currentStep;
|
||||||
{index < steps.length - 1 && (
|
const isActive = index === currentStep;
|
||||||
|
const isLocked = index > currentStep;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className={`mx-4 -mt-12 h-1 flex-1 transition-all duration-300 ${
|
key={index}
|
||||||
index < currentStep ? "bg-primary" : "bg-stroke"
|
className="relative flex flex-1 flex-col items-center"
|
||||||
}`}
|
>
|
||||||
/>
|
<button
|
||||||
)}
|
ref={(el) => {
|
||||||
</React.Fragment>
|
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>
|
||||||
|
|
||||||
<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
|
<button
|
||||||
|
ref={prevBtnRef}
|
||||||
onClick={prevStep}
|
onClick={prevStep}
|
||||||
|
onMouseEnter={() => handleButtonHover(prevBtnRef.current, true)}
|
||||||
|
onMouseLeave={() => handleButtonHover(prevBtnRef.current, false)}
|
||||||
disabled={currentStep === 0}
|
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
|
currentStep === 0
|
||||||
? "cursor-not-allowed bg-gray-1 text-gray-4"
|
? "cursor-not-allowed bg-gray-1 text-gray-4 dark:bg-dark-3"
|
||||||
: "bg-gray-2 text-gray-7 hover:bg-gray-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
|
Previous
|
||||||
</button>
|
</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
|
<button
|
||||||
|
ref={nextBtnRef}
|
||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
|
onMouseEnter={() => handleButtonHover(nextBtnRef.current, true)}
|
||||||
|
onMouseLeave={() => handleButtonHover(nextBtnRef.current, false)}
|
||||||
disabled={isSubmitting}
|
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
|
isSubmitting
|
||||||
? "cursor-not-allowed bg-gray-1 text-gray-4"
|
? "cursor-not-allowed bg-gray-4"
|
||||||
: "bg-primary text-white hover:bg-primary/90"
|
: "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
|
{/* Glow halo for submit */}
|
||||||
? "Submitting..."
|
{isLastStep && !isSubmitting && (
|
||||||
: currentStep === steps.length - 1
|
<span
|
||||||
? "Submit"
|
ref={submitGlowRef}
|
||||||
: "Next"}
|
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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user