@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
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@m13v/seo-components",
3
+ "version": "0.1.0",
4
+ "description": "39 animated React components for programmatic SEO pages. Remotion video, Magic UI style animations, trust signals, JSON-LD helpers. Teal/cyan brand, light-theme only.",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./components/*": "./src/components/*",
11
+ "./lib/*": "./src/lib/*"
12
+ },
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "keywords": [
17
+ "seo",
18
+ "components",
19
+ "react",
20
+ "nextjs",
21
+ "framer-motion",
22
+ "remotion",
23
+ "magic-ui",
24
+ "animated",
25
+ "programmatic-seo",
26
+ "json-ld"
27
+ ],
28
+ "author": "m13v",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/m13v/appmaker.git",
33
+ "directory": "seo-components"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "peerDependencies": {
39
+ "next": ">=14",
40
+ "react": ">=18",
41
+ "react-dom": ">=18",
42
+ "framer-motion": ">=11",
43
+ "remotion": ">=4",
44
+ "@remotion/player": ">=4",
45
+ "lottie-react": ">=2"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "remotion": { "optional": true },
49
+ "@remotion/player": { "optional": true },
50
+ "lottie-react": { "optional": true }
51
+ }
52
+ }
@@ -0,0 +1,273 @@
1
+ "use client";
2
+
3
+ import { motion } from "framer-motion";
4
+
5
+ interface BeamNode {
6
+ label: string;
7
+ sublabel?: string;
8
+ }
9
+
10
+ interface AnimatedBeamProps {
11
+ /** Left side nodes (sources) */
12
+ from: BeamNode[];
13
+ /** Single center node (aggregator) */
14
+ hub: BeamNode;
15
+ /** Right side nodes (destinations) */
16
+ to: BeamNode[];
17
+ title?: string;
18
+ className?: string;
19
+ }
20
+
21
+ /**
22
+ * Magic UI style animated beam diagram. A central hub connects to
23
+ * sources on the left and destinations on the right. Animated
24
+ * gradient beams travel along each connection. Pure SVG, no deps
25
+ * beyond framer-motion.
26
+ */
27
+ export function AnimatedBeam({
28
+ from,
29
+ hub,
30
+ to,
31
+ title,
32
+ className = "",
33
+ }: AnimatedBeamProps) {
34
+ const width = 720;
35
+ const height = 380;
36
+ const centerX = width / 2;
37
+ const centerY = height / 2;
38
+ const leftX = 96;
39
+ const rightX = width - 96;
40
+
41
+ const leftPositions = from.map(
42
+ (_, i) => (i + 1) * (height / (from.length + 1))
43
+ );
44
+ const rightPositions = to.map(
45
+ (_, i) => (i + 1) * (height / (to.length + 1))
46
+ );
47
+
48
+ return (
49
+ <div
50
+ className={`my-10 rounded-2xl border border-zinc-200 bg-white p-6 ${className}`}
51
+ >
52
+ {title && (
53
+ <h3 className="text-sm font-semibold text-zinc-900 mb-4">{title}</h3>
54
+ )}
55
+ <svg
56
+ viewBox={`0 0 ${width} ${height}`}
57
+ className="w-full h-auto"
58
+ preserveAspectRatio="xMidYMid meet"
59
+ >
60
+ <defs>
61
+ <linearGradient id="beam-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
62
+ <stop offset="0%" stopColor="#14b8a6" stopOpacity="0" />
63
+ <stop offset="50%" stopColor="#14b8a6" stopOpacity="1" />
64
+ <stop offset="100%" stopColor="#14b8a6" stopOpacity="0" />
65
+ </linearGradient>
66
+ <filter id="glow">
67
+ <feGaussianBlur stdDeviation="3" result="blur" />
68
+ <feMerge>
69
+ <feMergeNode in="blur" />
70
+ <feMergeNode in="SourceGraphic" />
71
+ </feMerge>
72
+ </filter>
73
+ </defs>
74
+
75
+ {/* Base connection lines */}
76
+ {leftPositions.map((y, i) => (
77
+ <path
78
+ key={`from-line-${i}`}
79
+ d={`M ${leftX + 60} ${y} Q ${centerX - 80} ${y}, ${centerX - 40} ${centerY}`}
80
+ stroke="#e4e4e7"
81
+ strokeWidth="2"
82
+ fill="none"
83
+ />
84
+ ))}
85
+ {rightPositions.map((y, i) => (
86
+ <path
87
+ key={`to-line-${i}`}
88
+ d={`M ${centerX + 40} ${centerY} Q ${centerX + 80} ${y}, ${rightX - 60} ${y}`}
89
+ stroke="#e4e4e7"
90
+ strokeWidth="2"
91
+ fill="none"
92
+ />
93
+ ))}
94
+
95
+ {/* Animated beam overlays */}
96
+ {leftPositions.map((y, i) => (
97
+ <motion.path
98
+ key={`from-beam-${i}`}
99
+ d={`M ${leftX + 60} ${y} Q ${centerX - 80} ${y}, ${centerX - 40} ${centerY}`}
100
+ stroke="url(#beam-gradient)"
101
+ strokeWidth="3"
102
+ fill="none"
103
+ strokeDasharray="40 160"
104
+ filter="url(#glow)"
105
+ initial={{ strokeDashoffset: 200 }}
106
+ animate={{ strokeDashoffset: 0 }}
107
+ transition={{
108
+ duration: 2,
109
+ repeat: Infinity,
110
+ ease: "linear",
111
+ delay: i * 0.3,
112
+ }}
113
+ />
114
+ ))}
115
+ {rightPositions.map((y, i) => (
116
+ <motion.path
117
+ key={`to-beam-${i}`}
118
+ d={`M ${centerX + 40} ${centerY} Q ${centerX + 80} ${y}, ${rightX - 60} ${y}`}
119
+ stroke="url(#beam-gradient)"
120
+ strokeWidth="3"
121
+ fill="none"
122
+ strokeDasharray="40 160"
123
+ filter="url(#glow)"
124
+ initial={{ strokeDashoffset: 200 }}
125
+ animate={{ strokeDashoffset: 0 }}
126
+ transition={{
127
+ duration: 2,
128
+ repeat: Infinity,
129
+ ease: "linear",
130
+ delay: i * 0.3 + 1,
131
+ }}
132
+ />
133
+ ))}
134
+
135
+ {/* Left nodes */}
136
+ {from.map((node, i) => (
137
+ <g key={`from-${i}`}>
138
+ <rect
139
+ x={leftX - 60}
140
+ y={leftPositions[i] - 24}
141
+ width="120"
142
+ height="48"
143
+ rx="8"
144
+ fill="white"
145
+ stroke="#e4e4e7"
146
+ strokeWidth="1.5"
147
+ />
148
+ <foreignObject
149
+ x={leftX - 56}
150
+ y={leftPositions[i] - 20}
151
+ width="112"
152
+ height="40"
153
+ >
154
+ <div
155
+ // @ts-expect-error -- xmlns is valid in foreignObject
156
+ xmlns="http://www.w3.org/1999/xhtml"
157
+ style={{
158
+ display: "flex",
159
+ alignItems: "center",
160
+ justifyContent: "center",
161
+ width: "100%",
162
+ height: "100%",
163
+ textAlign: "center",
164
+ fontSize: 12,
165
+ fontWeight: 600,
166
+ lineHeight: 1.2,
167
+ color: "#18181b",
168
+ overflow: "hidden",
169
+ wordBreak: "break-word",
170
+ }}
171
+ >
172
+ {node.label}
173
+ </div>
174
+ </foreignObject>
175
+ </g>
176
+ ))}
177
+
178
+ {/* Center hub */}
179
+ <g>
180
+ <rect
181
+ x={centerX - 72}
182
+ y={centerY - 36}
183
+ width="144"
184
+ height="72"
185
+ rx="36"
186
+ fill="#14b8a6"
187
+ filter="url(#glow)"
188
+ />
189
+ <rect
190
+ x={centerX - 72}
191
+ y={centerY - 36}
192
+ width="144"
193
+ height="72"
194
+ rx="36"
195
+ fill="none"
196
+ stroke="white"
197
+ strokeWidth="2"
198
+ />
199
+ <foreignObject
200
+ x={centerX - 64}
201
+ y={centerY - 28}
202
+ width="128"
203
+ height="56"
204
+ >
205
+ <div
206
+ // @ts-expect-error -- xmlns is valid in foreignObject
207
+ xmlns="http://www.w3.org/1999/xhtml"
208
+ style={{
209
+ display: "flex",
210
+ alignItems: "center",
211
+ justifyContent: "center",
212
+ width: "100%",
213
+ height: "100%",
214
+ textAlign: "center",
215
+ fontSize: 12,
216
+ fontWeight: 700,
217
+ lineHeight: 1.2,
218
+ color: "white",
219
+ overflow: "hidden",
220
+ wordBreak: "break-word",
221
+ }}
222
+ >
223
+ {hub.label}
224
+ </div>
225
+ </foreignObject>
226
+ </g>
227
+
228
+ {/* Right nodes */}
229
+ {to.map((node, i) => (
230
+ <g key={`to-${i}`}>
231
+ <rect
232
+ x={rightX - 60}
233
+ y={rightPositions[i] - 24}
234
+ width="120"
235
+ height="48"
236
+ rx="8"
237
+ fill="white"
238
+ stroke="#e4e4e7"
239
+ strokeWidth="1.5"
240
+ />
241
+ <foreignObject
242
+ x={rightX - 56}
243
+ y={rightPositions[i] - 20}
244
+ width="112"
245
+ height="40"
246
+ >
247
+ <div
248
+ // @ts-expect-error -- xmlns is valid in foreignObject
249
+ xmlns="http://www.w3.org/1999/xhtml"
250
+ style={{
251
+ display: "flex",
252
+ alignItems: "center",
253
+ justifyContent: "center",
254
+ width: "100%",
255
+ height: "100%",
256
+ textAlign: "center",
257
+ fontSize: 12,
258
+ fontWeight: 600,
259
+ lineHeight: 1.2,
260
+ color: "#18181b",
261
+ overflow: "hidden",
262
+ wordBreak: "break-word",
263
+ }}
264
+ >
265
+ {node.label}
266
+ </div>
267
+ </foreignObject>
268
+ </g>
269
+ ))}
270
+ </svg>
271
+ </div>
272
+ );
273
+ }
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { motion } from "framer-motion";
4
+
5
+ interface ChecklistItem {
6
+ text: string;
7
+ checked?: boolean;
8
+ }
9
+
10
+ interface AnimatedChecklistProps {
11
+ title: string;
12
+ items: ChecklistItem[];
13
+ }
14
+
15
+ export function AnimatedChecklist({ title, items }: AnimatedChecklistProps) {
16
+ return (
17
+ <motion.div
18
+ className="my-8 rounded-2xl border border-zinc-200 bg-white p-6"
19
+ initial={{ opacity: 0, y: 12 }}
20
+ whileInView={{ opacity: 1, y: 0 }}
21
+ viewport={{ once: true }}
22
+ transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
23
+ >
24
+ <p className="text-xs font-mono uppercase tracking-widest text-emerald-500 mb-4">
25
+ {title}
26
+ </p>
27
+ <ul className="space-y-2.5">
28
+ {items.map((item, i) => (
29
+ <motion.li
30
+ key={i}
31
+ className="flex items-start gap-3"
32
+ initial={{ opacity: 0, x: -12 }}
33
+ whileInView={{ opacity: 1, x: 0 }}
34
+ viewport={{ once: true }}
35
+ transition={{
36
+ duration: 0.3,
37
+ ease: [0.16, 1, 0.3, 1],
38
+ delay: i * 0.06,
39
+ }}
40
+ >
41
+ <motion.span
42
+ className={`mt-0.5 flex items-center justify-center w-5 h-5 rounded-md border shrink-0 ${
43
+ item.checked !== false
44
+ ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-500"
45
+ : "border-zinc-200 bg-white text-zinc-500"
46
+ }`}
47
+ initial={{ scale: 0 }}
48
+ whileInView={{ scale: 1 }}
49
+ viewport={{ once: true }}
50
+ transition={{ delay: i * 0.06 + 0.15, type: "spring", stiffness: 300, damping: 20 }}
51
+ >
52
+ {item.checked !== false ? (
53
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
54
+ <path d="M2 5l2.5 2.5L8 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
55
+ </svg>
56
+ ) : (
57
+ <span className="w-1.5 h-1.5 rounded-full bg-zinc-400" />
58
+ )}
59
+ </motion.span>
60
+ <span className={`text-sm leading-relaxed ${item.checked !== false ? "text-zinc-900" : "text-zinc-500"}`}>
61
+ {item.text}
62
+ </span>
63
+ </motion.li>
64
+ ))}
65
+ </ul>
66
+ </motion.div>
67
+ );
68
+ }
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { motion, useInView } from "framer-motion";
4
+ import { useRef, useState, useEffect } from "react";
5
+
6
+ interface AnimatedCodeBlockProps {
7
+ code: string;
8
+ language?: string;
9
+ filename?: string;
10
+ typingSpeed?: number;
11
+ }
12
+
13
+ export function AnimatedCodeBlock({
14
+ code,
15
+ language = "typescript",
16
+ filename,
17
+ typingSpeed = 12,
18
+ }: AnimatedCodeBlockProps) {
19
+ const ref = useRef<HTMLDivElement>(null);
20
+ const isInView = useInView(ref, { once: true, margin: "-60px" });
21
+ const [displayedLines, setDisplayedLines] = useState(0);
22
+ const lines = code.split("\n");
23
+
24
+ useEffect(() => {
25
+ if (!isInView) return;
26
+ let i = 0;
27
+ const interval = setInterval(() => {
28
+ i++;
29
+ setDisplayedLines(i);
30
+ if (i >= lines.length) clearInterval(interval);
31
+ }, typingSpeed);
32
+ return () => clearInterval(interval);
33
+ }, [isInView, lines.length, typingSpeed]);
34
+
35
+ return (
36
+ <motion.div
37
+ ref={ref}
38
+ className="my-6 rounded-2xl border border-zinc-800 bg-zinc-900 overflow-hidden"
39
+ initial={{ opacity: 0, y: 12 }}
40
+ whileInView={{ opacity: 1, y: 0 }}
41
+ viewport={{ once: true }}
42
+ transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
43
+ >
44
+ {filename && (
45
+ <div className="flex items-center gap-2 px-5 py-2.5 border-b border-zinc-800 bg-zinc-900/50">
46
+ <div className="flex gap-1.5">
47
+ <span className="w-2.5 h-2.5 rounded-full bg-red-400/60" />
48
+ <span className="w-2.5 h-2.5 rounded-full bg-amber-400/60" />
49
+ <span className="w-2.5 h-2.5 rounded-full bg-emerald-400/60" />
50
+ </div>
51
+ <span className="text-[11px] font-mono text-zinc-400 ml-1">{filename}</span>
52
+ </div>
53
+ )}
54
+ <pre className="p-5 text-sm font-mono overflow-x-auto">
55
+ <code className="text-code-text">
56
+ {lines.slice(0, displayedLines).join("\n")}
57
+ {displayedLines < lines.length && isInView && (
58
+ <motion.span
59
+ className="inline-block w-[7px] h-[14px] bg-emerald-500 ml-0.5 align-middle"
60
+ animate={{ opacity: [1, 0] }}
61
+ transition={{ duration: 0.6, repeat: Infinity, repeatType: "reverse" }}
62
+ />
63
+ )}
64
+ </code>
65
+ </pre>
66
+ </motion.div>
67
+ );
68
+ }
@@ -0,0 +1,186 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useRef } from "react";
4
+ import { motion, useInView } from "framer-motion";
5
+
6
+ interface DemoStep {
7
+ /** What appears in the "screen" */
8
+ screen: string;
9
+ /** What appears in the narration/caption */
10
+ caption: string;
11
+ /** Duration in ms before advancing to next step */
12
+ duration?: number;
13
+ }
14
+
15
+ interface AnimatedDemoProps {
16
+ title: string;
17
+ steps: DemoStep[];
18
+ /** Optional code that reproduces this demo */
19
+ code?: string;
20
+ codeLanguage?: string;
21
+ className?: string;
22
+ }
23
+
24
+ export function AnimatedDemo({
25
+ title,
26
+ steps,
27
+ code,
28
+ codeLanguage = "tsx",
29
+ className = "",
30
+ }: AnimatedDemoProps) {
31
+ const [currentStep, setCurrentStep] = useState(0);
32
+ const [isPlaying, setIsPlaying] = useState(false);
33
+ const [showCode, setShowCode] = useState(false);
34
+ const ref = useRef<HTMLDivElement>(null);
35
+ const isInView = useInView(ref, { once: true, margin: "-80px" });
36
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
37
+
38
+ useEffect(() => {
39
+ if (isInView && !isPlaying) {
40
+ setIsPlaying(true);
41
+ }
42
+ }, [isInView, isPlaying]);
43
+
44
+ useEffect(() => {
45
+ if (!isPlaying) return;
46
+ const step = steps[currentStep];
47
+ const dur = step?.duration || 2000;
48
+
49
+ timerRef.current = setTimeout(() => {
50
+ setCurrentStep((prev) => (prev + 1) % steps.length);
51
+ }, dur);
52
+
53
+ return () => {
54
+ if (timerRef.current) clearTimeout(timerRef.current);
55
+ };
56
+ }, [currentStep, isPlaying, steps]);
57
+
58
+ const step = steps[currentStep];
59
+
60
+ return (
61
+ <motion.div
62
+ ref={ref}
63
+ initial={{ opacity: 0, y: 24 }}
64
+ whileInView={{ opacity: 1, y: 0 }}
65
+ viewport={{ once: true, margin: "-60px" }}
66
+ transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
67
+ className={`my-10 ${className}`}
68
+ >
69
+ {/* Header */}
70
+ <div className="flex items-center justify-between mb-3">
71
+ <span className="text-xs font-mono font-medium text-teal-600 tracking-wider uppercase">
72
+ {title}
73
+ </span>
74
+ {code && (
75
+ <button
76
+ onClick={() => setShowCode(!showCode)}
77
+ className="text-xs text-zinc-400 hover:text-zinc-600 transition-colors font-mono"
78
+ >
79
+ {showCode ? "Hide code" : "View code"}
80
+ </button>
81
+ )}
82
+ </div>
83
+
84
+ {/* Demo screen */}
85
+ <div className="rounded-2xl border border-zinc-200 bg-zinc-950 overflow-hidden shadow-lg">
86
+ {/* Title bar */}
87
+ <div className="flex items-center gap-1.5 px-4 py-2.5 bg-zinc-900 border-b border-zinc-800">
88
+ <div className="w-2.5 h-2.5 rounded-full bg-red-400/80" />
89
+ <div className="w-2.5 h-2.5 rounded-full bg-yellow-400/80" />
90
+ <div className="w-2.5 h-2.5 rounded-full bg-green-400/80" />
91
+ <span className="ml-3 text-[11px] text-zinc-500 font-mono">
92
+ mk0r preview
93
+ </span>
94
+ </div>
95
+
96
+ {/* Screen content */}
97
+ <div className="relative min-h-[180px] flex items-center justify-center p-8">
98
+ <motion.div
99
+ key={currentStep}
100
+ initial={{ opacity: 0, scale: 0.96 }}
101
+ animate={{ opacity: 1, scale: 1 }}
102
+ exit={{ opacity: 0 }}
103
+ transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
104
+ className="text-center"
105
+ >
106
+ <p className="text-white font-mono text-sm leading-relaxed whitespace-pre-line">
107
+ {step?.screen}
108
+ </p>
109
+ </motion.div>
110
+ </div>
111
+
112
+ {/* Progress bar */}
113
+ <div className="h-0.5 bg-zinc-800 overflow-hidden">
114
+ <motion.div
115
+ key={`progress-${currentStep}`}
116
+ initial={{ width: "0%" }}
117
+ animate={{ width: "100%" }}
118
+ transition={{
119
+ duration: (step?.duration || 2000) / 1000,
120
+ ease: "linear",
121
+ }}
122
+ className="h-full bg-gradient-to-r from-cyan-500 to-teal-500"
123
+ />
124
+ </div>
125
+ </div>
126
+
127
+ {/* Caption */}
128
+ <motion.p
129
+ key={`caption-${currentStep}`}
130
+ initial={{ opacity: 0, y: 6 }}
131
+ animate={{ opacity: 1, y: 0 }}
132
+ transition={{ duration: 0.25 }}
133
+ className="mt-3 text-sm text-zinc-500 text-center"
134
+ >
135
+ <span className="inline-flex items-center gap-2">
136
+ <span className="text-xs font-mono text-teal-500">
137
+ {currentStep + 1}/{steps.length}
138
+ </span>
139
+ {step?.caption}
140
+ </span>
141
+ </motion.p>
142
+
143
+ {/* Step indicators */}
144
+ <div className="flex items-center justify-center gap-1.5 mt-3">
145
+ {steps.map((_, i) => (
146
+ <button
147
+ key={i}
148
+ onClick={() => setCurrentStep(i)}
149
+ className={`w-1.5 h-1.5 rounded-full transition-all duration-200 ${
150
+ i === currentStep
151
+ ? "bg-teal-500 w-4"
152
+ : i < currentStep
153
+ ? "bg-teal-300"
154
+ : "bg-zinc-300"
155
+ }`}
156
+ />
157
+ ))}
158
+ </div>
159
+
160
+ {/* Code panel */}
161
+ {code && showCode && (
162
+ <motion.div
163
+ initial={{ opacity: 0, height: 0 }}
164
+ animate={{ opacity: 1, height: "auto" }}
165
+ exit={{ opacity: 0, height: 0 }}
166
+ transition={{ duration: 0.3 }}
167
+ className="mt-4"
168
+ >
169
+ <div className="rounded-xl border border-zinc-200 bg-zinc-950 overflow-hidden">
170
+ <div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800">
171
+ <span className="text-[11px] text-zinc-500 font-mono">
172
+ {codeLanguage}
173
+ </span>
174
+ <span className="text-[10px] text-zinc-600 font-mono">
175
+ How to build this
176
+ </span>
177
+ </div>
178
+ <pre className="p-4 text-sm text-zinc-300 font-mono overflow-x-auto leading-relaxed">
179
+ <code>{code}</code>
180
+ </pre>
181
+ </div>
182
+ </motion.div>
183
+ )}
184
+ </motion.div>
185
+ );
186
+ }
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import { motion, useInView, useSpring, useMotionValue } from "framer-motion";
4
+ import { useRef, useEffect, useState } from "react";
5
+
6
+ interface AnimatedMetricProps {
7
+ value: number;
8
+ suffix?: string;
9
+ prefix?: string;
10
+ label: string;
11
+ decimals?: number;
12
+ }
13
+
14
+ export function AnimatedMetric({
15
+ value,
16
+ suffix = "",
17
+ prefix = "",
18
+ label,
19
+ decimals = 0,
20
+ }: AnimatedMetricProps) {
21
+ const ref = useRef<HTMLDivElement>(null);
22
+ const isInView = useInView(ref, { once: true, margin: "-40px" });
23
+ const motionVal = useMotionValue(0);
24
+ const spring = useSpring(motionVal, { stiffness: 60, damping: 20 });
25
+ const [display, setDisplay] = useState("0");
26
+
27
+ useEffect(() => {
28
+ if (isInView) motionVal.set(value);
29
+ }, [isInView, motionVal, value]);
30
+
31
+ useEffect(() => {
32
+ const unsubscribe = spring.on("change", (v: number) => {
33
+ setDisplay(v.toFixed(decimals));
34
+ });
35
+ return unsubscribe;
36
+ }, [spring, decimals]);
37
+
38
+ return (
39
+ <motion.div
40
+ ref={ref}
41
+ className="flex flex-col items-center p-4"
42
+ initial={{ opacity: 0, scale: 0.9 }}
43
+ whileInView={{ opacity: 1, scale: 1 }}
44
+ viewport={{ once: true }}
45
+ transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
46
+ >
47
+ <span className="text-3xl md:text-4xl font-bold text-teal-600 tabular-nums">
48
+ {prefix}{display}
49
+ {suffix && <span className="text-base font-semibold">{suffix}</span>}
50
+ </span>
51
+ <span className="text-xs text-zinc-500 mt-1 text-center">{label}</span>
52
+ </motion.div>
53
+ );
54
+ }