@m13v/seo-components 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +52 -0
  2. package/src/components/AnimatedBeam.tsx +273 -0
  3. package/src/components/AnimatedChecklist.tsx +68 -0
  4. package/src/components/AnimatedCodeBlock.tsx +68 -0
  5. package/src/components/AnimatedDemo.tsx +186 -0
  6. package/src/components/AnimatedMetric.tsx +54 -0
  7. package/src/components/AnimatedSection.tsx +31 -0
  8. package/src/components/ArticleMeta.tsx +51 -0
  9. package/src/components/BackgroundGrid.tsx +59 -0
  10. package/src/components/BeforeAfter.tsx +121 -0
  11. package/src/components/BentoGrid.tsx +67 -0
  12. package/src/components/Breadcrumbs.tsx +40 -0
  13. package/src/components/CodeComparison.tsx +115 -0
  14. package/src/components/ComparisonTable.tsx +66 -0
  15. package/src/components/FaqSection.tsx +40 -0
  16. package/src/components/FlowDiagram.tsx +86 -0
  17. package/src/components/GlowCard.tsx +58 -0
  18. package/src/components/GradientText.tsx +43 -0
  19. package/src/components/InlineCta.tsx +50 -0
  20. package/src/components/InlineTestimonial.tsx +53 -0
  21. package/src/components/LottiePlayer.tsx +63 -0
  22. package/src/components/Marquee.tsx +67 -0
  23. package/src/components/MetricsRow.tsx +32 -0
  24. package/src/components/MorphingText.tsx +133 -0
  25. package/src/components/MotionSequence.tsx +150 -0
  26. package/src/components/NumberTicker.tsx +68 -0
  27. package/src/components/OrbitingCircles.tsx +83 -0
  28. package/src/components/ParallaxSection.tsx +56 -0
  29. package/src/components/Particles.tsx +268 -0
  30. package/src/components/ProofBand.tsx +66 -0
  31. package/src/components/ProofBanner.tsx +31 -0
  32. package/src/components/RemotionClip.tsx +216 -0
  33. package/src/components/SequenceDiagram.tsx +144 -0
  34. package/src/components/ShimmerButton.tsx +51 -0
  35. package/src/components/ShineBorder.tsx +87 -0
  36. package/src/components/StepTimeline.tsx +108 -0
  37. package/src/components/StickyBottomCta.tsx +53 -0
  38. package/src/components/TerminalOutput.tsx +93 -0
  39. package/src/components/TextShimmer.tsx +59 -0
  40. package/src/components/TypingAnimation.tsx +54 -0
  41. package/src/index.ts +69 -0
  42. package/src/lib/json-ld.ts +116 -0
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from "react";
4
+ import { motion } from "framer-motion";
5
+
6
+ interface GlowCardProps {
7
+ children: React.ReactNode;
8
+ className?: string;
9
+ glowColor?: string;
10
+ }
11
+
12
+ /**
13
+ * Card with a mouse-tracking glow effect (like Linear/Stripe).
14
+ * The glow follows the cursor position on hover.
15
+ */
16
+ export function GlowCard({
17
+ children,
18
+ className = "",
19
+ glowColor = "rgba(20, 184, 166, 0.15)",
20
+ }: GlowCardProps) {
21
+ const ref = useRef<HTMLDivElement>(null);
22
+ const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
23
+ const [isHovered, setIsHovered] = useState(false);
24
+
25
+ function handleMouseMove(e: React.MouseEvent) {
26
+ if (!ref.current) return;
27
+ const rect = ref.current.getBoundingClientRect();
28
+ setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
29
+ }
30
+
31
+ return (
32
+ <motion.div
33
+ ref={ref}
34
+ initial={{ opacity: 0, y: 16 }}
35
+ whileInView={{ opacity: 1, y: 0 }}
36
+ viewport={{ once: true, margin: "-40px" }}
37
+ transition={{ duration: 0.45, ease: [0.16, 1, 0.3, 1] }}
38
+ onMouseMove={handleMouseMove}
39
+ onMouseEnter={() => setIsHovered(true)}
40
+ onMouseLeave={() => setIsHovered(false)}
41
+ className={`relative rounded-2xl border border-zinc-200 bg-white overflow-hidden transition-shadow duration-300 ${
42
+ isHovered ? "shadow-lg border-teal-200" : ""
43
+ } ${className}`}
44
+ >
45
+ {/* Glow overlay */}
46
+ <div
47
+ className="pointer-events-none absolute inset-0 transition-opacity duration-300"
48
+ style={{
49
+ opacity: isHovered ? 1 : 0,
50
+ background: `radial-gradient(400px circle at ${mousePos.x}px ${mousePos.y}px, ${glowColor}, transparent 60%)`,
51
+ }}
52
+ />
53
+
54
+ {/* Content */}
55
+ <div className="relative z-10">{children}</div>
56
+ </motion.div>
57
+ );
58
+ }
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { motion } from "framer-motion";
4
+
5
+ interface GradientTextProps {
6
+ children: React.ReactNode;
7
+ /** "teal" (default) uses cyan → teal brand gradient. "rainbow" uses a wider cyan/teal/emerald spread. */
8
+ variant?: "teal" | "rainbow";
9
+ /** Animate the gradient sliding horizontally */
10
+ animate?: boolean;
11
+ className?: string;
12
+ }
13
+
14
+ /**
15
+ * Gradient text treatment for hero headings or standout words.
16
+ * Stays on-brand (teal/cyan, never violet). Optional animated
17
+ * background-position shift creates a subtle moving gradient.
18
+ */
19
+ export function GradientText({
20
+ children,
21
+ variant = "teal",
22
+ animate = true,
23
+ className = "",
24
+ }: GradientTextProps) {
25
+ const gradient =
26
+ variant === "rainbow"
27
+ ? "linear-gradient(90deg, #06b6d4, #14b8a6, #10b981, #14b8a6, #06b6d4)"
28
+ : "linear-gradient(90deg, #06b6d4, #14b8a6, #0d9488, #14b8a6, #06b6d4)";
29
+
30
+ return (
31
+ <motion.span
32
+ className={`inline-block bg-clip-text text-transparent ${className}`}
33
+ style={{
34
+ backgroundImage: gradient,
35
+ backgroundSize: "200% 100%",
36
+ }}
37
+ animate={animate ? { backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"] } : undefined}
38
+ transition={animate ? { duration: 6, ease: "linear", repeat: Infinity } : undefined}
39
+ >
40
+ {children}
41
+ </motion.span>
42
+ );
43
+ }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { motion } from "framer-motion";
4
+
5
+ interface InlineCtaProps {
6
+ heading: string;
7
+ body: string;
8
+ linkText?: string;
9
+ href?: string;
10
+ }
11
+
12
+ export function InlineCta({
13
+ heading,
14
+ body,
15
+ linkText = "Get Started",
16
+ href = "#",
17
+ }: InlineCtaProps) {
18
+ return (
19
+ <motion.div
20
+ className="my-12 p-6 rounded-2xl border border-accent/20 bg-accent-surface"
21
+ initial={{ opacity: 0, y: 16 }}
22
+ whileInView={{ opacity: 1, y: 0 }}
23
+ viewport={{ once: true }}
24
+ transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
25
+ >
26
+ <p className="text-sm font-semibold text-teal-600 mb-2">
27
+ {heading}
28
+ </p>
29
+ <p className="text-sm text-zinc-500 mb-4">
30
+ {body}
31
+ </p>
32
+ <a
33
+ href={href}
34
+ className="inline-flex items-center gap-2 text-sm font-medium text-teal-600 hover:text-accent-dim transition-colors"
35
+ onClick={() => {
36
+ const w = typeof window !== "undefined" ? (window as unknown as { posthog?: { capture: (e: string, p?: Record<string, unknown>) => void } }) : undefined;
37
+ w?.posthog?.capture("cta_clicked", {
38
+ component: "InlineCta",
39
+ heading,
40
+ label: linkText,
41
+ destination: href,
42
+ page: typeof window !== "undefined" ? window.location.pathname : undefined,
43
+ });
44
+ }}
45
+ >
46
+ {linkText} <span>&rarr;</span>
47
+ </a>
48
+ </motion.div>
49
+ );
50
+ }
@@ -0,0 +1,53 @@
1
+ interface InlineTestimonialProps {
2
+ quote: string;
3
+ name: string;
4
+ role?: string;
5
+ stars?: 1 | 2 | 3 | 4 | 5;
6
+ className?: string;
7
+ }
8
+
9
+ function Stars({ count }: { count: number }) {
10
+ return (
11
+ <div className="flex items-center gap-0.5" aria-label={`${count} out of 5 stars`}>
12
+ {Array.from({ length: 5 }, (_, i) => (
13
+ <svg
14
+ key={i}
15
+ className={`w-4 h-4 ${i < count ? "text-teal-600" : "text-zinc-200"}`}
16
+ fill="currentColor"
17
+ viewBox="0 0 20 20"
18
+ aria-hidden="true"
19
+ >
20
+ <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.363 1.118l1.287 3.957c.3.922-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.175 0l-3.37 2.448c-.784.57-1.838-.196-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.05 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" />
21
+ </svg>
22
+ ))}
23
+ </div>
24
+ );
25
+ }
26
+
27
+ export function InlineTestimonial({
28
+ quote,
29
+ name,
30
+ role,
31
+ stars = 5,
32
+ className = "",
33
+ }: InlineTestimonialProps) {
34
+ return (
35
+ <figure
36
+ className={`p-6 sm:p-8 rounded-2xl bg-zinc-50 border border-zinc-200 ${className}`}
37
+ >
38
+ <Stars count={stars} />
39
+ <blockquote className="mt-4 text-lg sm:text-xl leading-relaxed text-zinc-800">
40
+ &ldquo;{quote}&rdquo;
41
+ </blockquote>
42
+ <figcaption className="mt-5 flex items-center gap-3">
43
+ <div className="w-9 h-9 rounded-full bg-gradient-to-br from-teal-500/40 to-teal-500/10 flex items-center justify-center text-sm font-semibold text-white">
44
+ {name.charAt(0)}
45
+ </div>
46
+ <div>
47
+ <div className="text-sm font-medium text-zinc-900">{name}</div>
48
+ {role && <div className="text-xs text-zinc-500">{role}</div>}
49
+ </div>
50
+ </figcaption>
51
+ </figure>
52
+ );
53
+ }
@@ -0,0 +1,63 @@
1
+ "use client";
2
+
3
+ import dynamic from "next/dynamic";
4
+ import { useEffect, useState } from "react";
5
+
6
+ const Lottie = dynamic(() => import("lottie-react"), { ssr: false });
7
+
8
+ interface LottiePlayerProps {
9
+ /** Lottie JSON data — inline or via import */
10
+ animationData?: unknown;
11
+ /** Path to a Lottie JSON file. Will be fetched at runtime. */
12
+ src?: string;
13
+ loop?: boolean;
14
+ autoplay?: boolean;
15
+ className?: string;
16
+ height?: number | string;
17
+ width?: number | string;
18
+ }
19
+
20
+ /**
21
+ * Lottie animation player. Pass either animationData (JSON object)
22
+ * or src (URL/path). Loads client-side only (ssr: false) because
23
+ * lottie-web needs window.
24
+ */
25
+ export function LottiePlayer({
26
+ animationData,
27
+ src,
28
+ loop = true,
29
+ autoplay = true,
30
+ className = "",
31
+ height = 240,
32
+ width = "100%",
33
+ }: LottiePlayerProps) {
34
+ const [data, setData] = useState<unknown>(animationData ?? null);
35
+
36
+ useEffect(() => {
37
+ if (data || !src) return;
38
+ fetch(src)
39
+ .then((r) => r.json())
40
+ .then(setData)
41
+ .catch(() => {});
42
+ }, [data, src]);
43
+
44
+ if (!data) {
45
+ return (
46
+ <div
47
+ className={`my-6 rounded-xl border border-zinc-200 bg-zinc-50 ${className}`}
48
+ style={{ height, width }}
49
+ />
50
+ );
51
+ }
52
+
53
+ return (
54
+ <div className={`my-6 ${className}`} style={{ height, width }}>
55
+ <Lottie
56
+ animationData={data}
57
+ loop={loop}
58
+ autoplay={autoplay}
59
+ style={{ height: "100%", width: "100%" }}
60
+ />
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { motion } from "framer-motion";
5
+
6
+ interface MarqueeProps {
7
+ children: React.ReactNode;
8
+ /** Duration of one full cycle in seconds. Lower = faster. */
9
+ speed?: number;
10
+ /** Reverse direction */
11
+ reverse?: boolean;
12
+ /** Pause on hover */
13
+ pauseOnHover?: boolean;
14
+ /** Fade edges for soft bleed */
15
+ fade?: boolean;
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * Infinite horizontal marquee. Duplicates children twice and
21
+ * translates the wrapper 50% so the loop is seamless. Wrap
22
+ * anything: logos, testimonials, tags, chips, metric cards.
23
+ */
24
+ export function Marquee({
25
+ children,
26
+ speed = 30,
27
+ reverse = false,
28
+ pauseOnHover = true,
29
+ fade = true,
30
+ className = "",
31
+ }: MarqueeProps) {
32
+ const [hover, setHover] = useState(false);
33
+
34
+ return (
35
+ <div
36
+ className={`relative overflow-hidden my-8 ${className}`}
37
+ onMouseEnter={() => pauseOnHover && setHover(true)}
38
+ onMouseLeave={() => pauseOnHover && setHover(false)}
39
+ style={{
40
+ maskImage: fade
41
+ ? "linear-gradient(90deg, transparent, black 8%, black 92%, transparent)"
42
+ : undefined,
43
+ WebkitMaskImage: fade
44
+ ? "linear-gradient(90deg, transparent, black 8%, black 92%, transparent)"
45
+ : undefined,
46
+ }}
47
+ >
48
+ <motion.div
49
+ className="flex gap-6 w-max"
50
+ animate={{
51
+ x: reverse ? ["-50%", "0%"] : ["0%", "-50%"],
52
+ }}
53
+ transition={{
54
+ duration: speed,
55
+ ease: "linear",
56
+ repeat: Infinity,
57
+ }}
58
+ style={{ animationPlayState: hover ? "paused" : "running" }}
59
+ >
60
+ <div className="flex gap-6 shrink-0">{children}</div>
61
+ <div className="flex gap-6 shrink-0" aria-hidden>
62
+ {children}
63
+ </div>
64
+ </motion.div>
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,32 @@
1
+ "use client";
2
+
3
+ import { motion } from "framer-motion";
4
+ import { AnimatedMetric } from "./AnimatedMetric";
5
+
6
+ interface Metric {
7
+ value: number;
8
+ suffix?: string;
9
+ prefix?: string;
10
+ label: string;
11
+ decimals?: number;
12
+ }
13
+
14
+ interface MetricsRowProps {
15
+ metrics: Metric[];
16
+ }
17
+
18
+ export function MetricsRow({ metrics }: MetricsRowProps) {
19
+ return (
20
+ <motion.div
21
+ className="my-10 rounded-2xl border border-zinc-200 bg-white grid grid-cols-2 md:grid-cols-4 divide-x divide-zinc-200"
22
+ initial={{ opacity: 0, y: 12 }}
23
+ whileInView={{ opacity: 1, y: 0 }}
24
+ viewport={{ once: true }}
25
+ transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
26
+ >
27
+ {metrics.map((m, i) => (
28
+ <AnimatedMetric key={i} {...m} />
29
+ ))}
30
+ </motion.div>
31
+ );
32
+ }
@@ -0,0 +1,133 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef } from "react";
4
+
5
+ /**
6
+ * MorphingText: cycles through an array of strings with a smooth blur/opacity
7
+ * morph transition. Useful for hero headlines that rotate through value props
8
+ * or keywords. Purely CSS + requestAnimationFrame, no framer-motion needed.
9
+ *
10
+ * @example
11
+ * <MorphingText texts={["Ship faster", "Convert more", "Rank higher"]} />
12
+ */
13
+
14
+ const MORPH_TIME = 1;
15
+ const COOLDOWN_TIME = 0.5;
16
+
17
+ function useMorphingText(texts: string[]) {
18
+ const textIndexRef = useRef(0);
19
+ const morphRef = useRef(0);
20
+ const cooldownRef = useRef(0);
21
+ const timeRef = useRef(new Date());
22
+
23
+ const text1Ref = useRef<HTMLSpanElement>(null);
24
+ const text2Ref = useRef<HTMLSpanElement>(null);
25
+
26
+ const setStyles = useCallback(
27
+ (fraction: number) => {
28
+ const [current1, current2] = [text1Ref.current, text2Ref.current];
29
+ if (!current1 || !current2) return;
30
+
31
+ current2.style.filter = `blur(${Math.min(4 / fraction - 4, 4)}px)`;
32
+ current2.style.opacity = `${Math.pow(fraction, 1.5) * 100}%`;
33
+
34
+ const invertedFraction = 1 - fraction;
35
+ current1.style.filter = `blur(${Math.min(
36
+ 4 / invertedFraction - 4,
37
+ 4
38
+ )}px)`;
39
+ current1.style.opacity = `${Math.pow(invertedFraction, 3) * 100}%`;
40
+
41
+ current1.textContent = texts[textIndexRef.current % texts.length];
42
+ current2.textContent =
43
+ texts[(textIndexRef.current + 1) % texts.length];
44
+ },
45
+ [texts]
46
+ );
47
+
48
+ const doMorph = useCallback(() => {
49
+ morphRef.current -= cooldownRef.current;
50
+ cooldownRef.current = 0;
51
+
52
+ let fraction = morphRef.current / MORPH_TIME;
53
+
54
+ if (fraction > 1) {
55
+ cooldownRef.current = COOLDOWN_TIME;
56
+ fraction = 1;
57
+ }
58
+
59
+ setStyles(fraction);
60
+
61
+ if (fraction === 1) {
62
+ textIndexRef.current++;
63
+ }
64
+ }, [setStyles]);
65
+
66
+ const doCooldown = useCallback(() => {
67
+ morphRef.current = 0;
68
+ const [current1, current2] = [text1Ref.current, text2Ref.current];
69
+ if (current1 && current2) {
70
+ current2.style.filter = "none";
71
+ current2.style.opacity = "100%";
72
+ current1.style.filter = "none";
73
+ current1.style.opacity = "0%";
74
+ }
75
+ }, []);
76
+
77
+ useEffect(() => {
78
+ let animationFrameId: number;
79
+
80
+ const animate = () => {
81
+ animationFrameId = requestAnimationFrame(animate);
82
+
83
+ const newTime = new Date();
84
+ const dt = (newTime.getTime() - timeRef.current.getTime()) / 1000;
85
+ timeRef.current = newTime;
86
+
87
+ cooldownRef.current -= dt;
88
+
89
+ if (cooldownRef.current <= 0) doMorph();
90
+ else doCooldown();
91
+ };
92
+
93
+ animate();
94
+ return () => {
95
+ cancelAnimationFrame(animationFrameId);
96
+ };
97
+ }, [doMorph, doCooldown]);
98
+
99
+ return { text1Ref, text2Ref };
100
+ }
101
+
102
+ interface MorphingTextProps {
103
+ /** Array of strings to cycle through */
104
+ texts: string[];
105
+ /** Additional Tailwind classes for the outer wrapper */
106
+ className?: string;
107
+ }
108
+
109
+ function Texts({ texts }: { texts: string[] }) {
110
+ const { text1Ref, text2Ref } = useMorphingText(texts);
111
+ return (
112
+ <>
113
+ <span
114
+ className="absolute inset-x-0 top-0 m-auto inline-block w-full text-center text-zinc-900 [will-change:filter,opacity] antialiased"
115
+ ref={text1Ref}
116
+ />
117
+ <span
118
+ className="absolute inset-x-0 top-0 m-auto inline-block w-full text-center text-zinc-900 [will-change:filter,opacity] antialiased"
119
+ ref={text2Ref}
120
+ />
121
+ </>
122
+ );
123
+ }
124
+
125
+ export function MorphingText({ texts, className }: MorphingTextProps) {
126
+ const base =
127
+ "relative mx-auto h-16 w-full max-w-screen-xl text-center text-[40pt] leading-none font-extrabold tracking-tight md:h-24 lg:text-[6rem] [text-rendering:optimizeLegibility] [font-smooth:antialiased] [-webkit-font-smoothing:antialiased] [-moz-osx-font-smoothing:grayscale]";
128
+ return (
129
+ <div className={className ? `${base} ${className}` : base}>
130
+ <Texts texts={texts} />
131
+ </div>
132
+ );
133
+ }
@@ -0,0 +1,150 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+
6
+ interface SequenceFrame {
7
+ title?: string;
8
+ body?: React.ReactNode;
9
+ visual?: React.ReactNode;
10
+ duration?: number;
11
+ }
12
+
13
+ interface MotionSequenceProps {
14
+ title?: string;
15
+ frames: SequenceFrame[];
16
+ defaultDuration?: number;
17
+ loop?: boolean;
18
+ className?: string;
19
+ }
20
+
21
+ /**
22
+ * Video-style timeline animation. Plays through a list of frames
23
+ * with timed reveals, like a Remotion composition rendered live.
24
+ * Each frame has a duration; the sequence advances automatically
25
+ * when the component scrolls into view.
26
+ */
27
+ export function MotionSequence({
28
+ title,
29
+ frames,
30
+ defaultDuration = 2400,
31
+ loop = true,
32
+ className = "",
33
+ }: MotionSequenceProps) {
34
+ const [index, setIndex] = useState(0);
35
+ const [playing, setPlaying] = useState(false);
36
+ const containerRef = useRef<HTMLDivElement>(null);
37
+
38
+ useEffect(() => {
39
+ const el = containerRef.current;
40
+ if (!el) return;
41
+ const observer = new IntersectionObserver(
42
+ ([entry]) => {
43
+ if (entry.isIntersecting) setPlaying(true);
44
+ },
45
+ { threshold: 0.3 }
46
+ );
47
+ observer.observe(el);
48
+ return () => observer.disconnect();
49
+ }, []);
50
+
51
+ useEffect(() => {
52
+ if (!playing) return;
53
+ const current = frames[index];
54
+ const d = current?.duration ?? defaultDuration;
55
+ const t = setTimeout(() => {
56
+ if (index < frames.length - 1) {
57
+ setIndex(index + 1);
58
+ } else if (loop) {
59
+ setIndex(0);
60
+ }
61
+ }, d);
62
+ return () => clearTimeout(t);
63
+ }, [index, playing, frames, defaultDuration, loop]);
64
+
65
+ const current = frames[index];
66
+ const total = frames.length;
67
+
68
+ return (
69
+ <div
70
+ ref={containerRef}
71
+ className={`my-10 rounded-2xl border border-zinc-200 bg-white overflow-hidden ${className}`}
72
+ >
73
+ {title && (
74
+ <div className="border-b border-zinc-100 px-6 py-4 flex items-center justify-between">
75
+ <h3 className="text-sm font-semibold text-zinc-900">{title}</h3>
76
+ <div className="flex items-center gap-2">
77
+ <div className="flex gap-1">
78
+ {frames.map((_, i) => (
79
+ <button
80
+ key={i}
81
+ onClick={() => setIndex(i)}
82
+ className={`h-1.5 rounded-full transition-all ${
83
+ i === index ? "w-6 bg-teal-500" : "w-1.5 bg-zinc-300"
84
+ }`}
85
+ aria-label={`Frame ${i + 1}`}
86
+ />
87
+ ))}
88
+ </div>
89
+ <span className="text-xs font-mono text-zinc-500 tabular-nums">
90
+ {String(index + 1).padStart(2, "0")} / {String(total).padStart(2, "0")}
91
+ </span>
92
+ </div>
93
+ </div>
94
+ )}
95
+
96
+ <div className="relative min-h-[280px] bg-gradient-to-br from-zinc-50 to-white">
97
+ <AnimatePresence mode="wait">
98
+ <motion.div
99
+ key={index}
100
+ initial={{ opacity: 0, y: 16, scale: 0.98 }}
101
+ animate={{ opacity: 1, y: 0, scale: 1 }}
102
+ exit={{ opacity: 0, y: -16, scale: 0.98 }}
103
+ transition={{
104
+ duration: 0.5,
105
+ ease: [0.16, 1, 0.3, 1],
106
+ }}
107
+ className="absolute inset-0 p-8 flex flex-col items-center justify-center"
108
+ >
109
+ {current?.visual && (
110
+ <div className="mb-6 w-full flex justify-center">
111
+ {current.visual}
112
+ </div>
113
+ )}
114
+ {current?.title && (
115
+ <motion.h4
116
+ initial={{ opacity: 0, y: 8 }}
117
+ animate={{ opacity: 1, y: 0 }}
118
+ transition={{ delay: 0.15, duration: 0.4 }}
119
+ className="text-xl font-semibold text-zinc-900 text-center mb-2"
120
+ >
121
+ {current.title}
122
+ </motion.h4>
123
+ )}
124
+ {current?.body && (
125
+ <motion.div
126
+ initial={{ opacity: 0, y: 8 }}
127
+ animate={{ opacity: 1, y: 0 }}
128
+ transition={{ delay: 0.25, duration: 0.4 }}
129
+ className="text-sm text-zinc-500 text-center max-w-md leading-relaxed"
130
+ >
131
+ {current.body}
132
+ </motion.div>
133
+ )}
134
+ </motion.div>
135
+ </AnimatePresence>
136
+ </div>
137
+
138
+ <motion.div
139
+ key={index}
140
+ initial={{ width: "0%" }}
141
+ animate={{ width: "100%" }}
142
+ transition={{
143
+ duration: (current?.duration ?? defaultDuration) / 1000,
144
+ ease: "linear",
145
+ }}
146
+ className="h-0.5 bg-gradient-to-r from-cyan-500 to-teal-500"
147
+ />
148
+ </div>
149
+ );
150
+ }