@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.
- package/package.json +52 -0
- package/src/components/AnimatedBeam.tsx +273 -0
- package/src/components/AnimatedChecklist.tsx +68 -0
- package/src/components/AnimatedCodeBlock.tsx +68 -0
- package/src/components/AnimatedDemo.tsx +186 -0
- package/src/components/AnimatedMetric.tsx +54 -0
- package/src/components/AnimatedSection.tsx +31 -0
- package/src/components/ArticleMeta.tsx +51 -0
- package/src/components/BackgroundGrid.tsx +59 -0
- package/src/components/BeforeAfter.tsx +121 -0
- package/src/components/BentoGrid.tsx +67 -0
- package/src/components/Breadcrumbs.tsx +40 -0
- package/src/components/CodeComparison.tsx +115 -0
- package/src/components/ComparisonTable.tsx +66 -0
- package/src/components/FaqSection.tsx +40 -0
- package/src/components/FlowDiagram.tsx +86 -0
- package/src/components/GlowCard.tsx +58 -0
- package/src/components/GradientText.tsx +43 -0
- package/src/components/InlineCta.tsx +50 -0
- package/src/components/InlineTestimonial.tsx +53 -0
- package/src/components/LottiePlayer.tsx +63 -0
- package/src/components/Marquee.tsx +67 -0
- package/src/components/MetricsRow.tsx +32 -0
- package/src/components/MorphingText.tsx +133 -0
- package/src/components/MotionSequence.tsx +150 -0
- package/src/components/NumberTicker.tsx +68 -0
- package/src/components/OrbitingCircles.tsx +83 -0
- package/src/components/ParallaxSection.tsx +56 -0
- package/src/components/Particles.tsx +268 -0
- package/src/components/ProofBand.tsx +66 -0
- package/src/components/ProofBanner.tsx +31 -0
- package/src/components/RemotionClip.tsx +216 -0
- package/src/components/SequenceDiagram.tsx +144 -0
- package/src/components/ShimmerButton.tsx +51 -0
- package/src/components/ShineBorder.tsx +87 -0
- package/src/components/StepTimeline.tsx +108 -0
- package/src/components/StickyBottomCta.tsx +53 -0
- package/src/components/TerminalOutput.tsx +93 -0
- package/src/components/TextShimmer.tsx +59 -0
- package/src/components/TypingAnimation.tsx +54 -0
- package/src/index.ts +69 -0
- 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
|
+
}
|