@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,68 @@
1
+ "use client";
2
+
3
+ import { motion, useInView, useMotionValue, useSpring } from "framer-motion";
4
+ import { useEffect, useRef } from "react";
5
+
6
+ interface NumberTickerProps {
7
+ value: number;
8
+ /** Number of decimal places */
9
+ decimals?: number;
10
+ /** Duration of the count-up animation in seconds */
11
+ duration?: number;
12
+ /** Optional prefix (e.g. "$") */
13
+ prefix?: string;
14
+ /** Optional suffix (e.g. "%") */
15
+ suffix?: string;
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * Magic UI style number ticker. Counts up from 0 to the target
21
+ * value with spring physics when scrolled into view. Smoother
22
+ * than a plain `useEffect` interval because it uses framer-motion
23
+ * motion values.
24
+ */
25
+ export function NumberTicker({
26
+ value,
27
+ decimals = 0,
28
+ duration = 1.6,
29
+ prefix = "",
30
+ suffix = "",
31
+ className = "",
32
+ }: NumberTickerProps) {
33
+ const ref = useRef<HTMLSpanElement>(null);
34
+ const inView = useInView(ref, { once: true, margin: "-40px" });
35
+ const motionValue = useMotionValue(0);
36
+ const springValue = useSpring(motionValue, {
37
+ duration: duration * 1000,
38
+ bounce: 0,
39
+ });
40
+
41
+ useEffect(() => {
42
+ if (inView) motionValue.set(value);
43
+ }, [inView, value, motionValue]);
44
+
45
+ useEffect(() => {
46
+ const unsub = springValue.on("change", (latest) => {
47
+ if (ref.current) {
48
+ ref.current.textContent =
49
+ prefix +
50
+ latest.toLocaleString("en-US", {
51
+ minimumFractionDigits: decimals,
52
+ maximumFractionDigits: decimals,
53
+ }) +
54
+ suffix;
55
+ }
56
+ });
57
+ return () => unsub();
58
+ }, [springValue, decimals, prefix, suffix]);
59
+
60
+ return (
61
+ <motion.span
62
+ ref={ref}
63
+ className={`tabular-nums font-bold text-zinc-900 ${className}`}
64
+ >
65
+ {prefix}0{suffix}
66
+ </motion.span>
67
+ );
68
+ }
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ import { motion } from "framer-motion";
4
+
5
+ interface OrbitingCirclesProps {
6
+ /** Center element (logo, icon, label) */
7
+ center: React.ReactNode;
8
+ /** Items orbiting around the center */
9
+ items: React.ReactNode[];
10
+ /** Radius in pixels */
11
+ radius?: number;
12
+ /** Duration of one full revolution in seconds */
13
+ duration?: number;
14
+ /** Reverse spin direction */
15
+ reverse?: boolean;
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * Magic UI style orbiting element. A central element with smaller
21
+ * items circling around it. Useful for "ecosystem" or "integrations"
22
+ * showcases where the center is the product and orbits are
23
+ * connected tools.
24
+ */
25
+ export function OrbitingCircles({
26
+ center,
27
+ items,
28
+ radius = 140,
29
+ duration = 20,
30
+ reverse = false,
31
+ className = "",
32
+ }: OrbitingCirclesProps) {
33
+ const size = radius * 2 + 80;
34
+
35
+ return (
36
+ <div
37
+ className={`my-10 relative flex items-center justify-center ${className}`}
38
+ style={{ width: size, height: size, maxWidth: "100%", margin: "2.5rem auto" }}
39
+ >
40
+ {/* Orbit ring */}
41
+ <div
42
+ className="absolute rounded-full border border-dashed border-zinc-200"
43
+ style={{ width: radius * 2, height: radius * 2 }}
44
+ />
45
+
46
+ {/* Center */}
47
+ <div className="relative z-10 flex items-center justify-center rounded-2xl bg-gradient-to-br from-cyan-500 to-teal-500 text-white font-semibold shadow-lg shadow-teal-500/30"
48
+ style={{ width: 96, height: 96 }}
49
+ >
50
+ {center}
51
+ </div>
52
+
53
+ {/* Orbiting items */}
54
+ <motion.div
55
+ className="absolute inset-0"
56
+ animate={{ rotate: reverse ? -360 : 360 }}
57
+ transition={{ duration, ease: "linear", repeat: Infinity }}
58
+ >
59
+ {items.map((item, i) => {
60
+ const angle = (i * 360) / items.length;
61
+ const rad = (angle * Math.PI) / 180;
62
+ const x = Math.cos(rad) * radius;
63
+ const y = Math.sin(rad) * radius;
64
+ return (
65
+ <motion.div
66
+ key={i}
67
+ className="absolute top-1/2 left-1/2 flex items-center justify-center rounded-xl bg-white border border-zinc-200 shadow-sm text-xs font-medium text-zinc-700"
68
+ style={{
69
+ width: 64,
70
+ height: 64,
71
+ transform: `translate(${x - 32}px, ${y - 32}px)`,
72
+ }}
73
+ animate={{ rotate: reverse ? 360 : -360 }}
74
+ transition={{ duration, ease: "linear", repeat: Infinity }}
75
+ >
76
+ {item}
77
+ </motion.div>
78
+ );
79
+ })}
80
+ </motion.div>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { useRef } from "react";
4
+ import { motion, useScroll, useTransform } from "framer-motion";
5
+
6
+ interface ParallaxSectionProps {
7
+ children: React.ReactNode;
8
+ /** Background element that moves at a different scroll speed */
9
+ background?: React.ReactNode;
10
+ /** Parallax intensity: 0 = none, 1 = max. Default 0.3 */
11
+ intensity?: number;
12
+ className?: string;
13
+ }
14
+
15
+ /**
16
+ * Section with a parallax scrolling effect on the background.
17
+ * The foreground content scrolls normally while the background
18
+ * moves at a slower rate, creating depth.
19
+ */
20
+ export function ParallaxSection({
21
+ children,
22
+ background,
23
+ intensity = 0.3,
24
+ className = "",
25
+ }: ParallaxSectionProps) {
26
+ const ref = useRef<HTMLDivElement>(null);
27
+ const { scrollYProgress } = useScroll({
28
+ target: ref,
29
+ offset: ["start end", "end start"],
30
+ });
31
+
32
+ const y = useTransform(scrollYProgress, [0, 1], [
33
+ -80 * intensity,
34
+ 80 * intensity,
35
+ ]);
36
+ const opacity = useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]);
37
+
38
+ return (
39
+ <div ref={ref} className={`relative overflow-hidden my-10 ${className}`}>
40
+ {/* Parallax background */}
41
+ {background && (
42
+ <motion.div
43
+ style={{ y }}
44
+ className="absolute inset-0 pointer-events-none"
45
+ >
46
+ {background}
47
+ </motion.div>
48
+ )}
49
+
50
+ {/* Foreground content */}
51
+ <motion.div style={{ opacity }} className="relative z-10">
52
+ {children}
53
+ </motion.div>
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,268 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef, useState } from "react";
4
+
5
+ /**
6
+ * Particles: renders an interactive canvas of floating particles that
7
+ * subtly follow the mouse cursor. Great for ambient hero backgrounds.
8
+ * Default color is teal (#14b8a6). Fully self-contained, canvas-based.
9
+ *
10
+ * @example
11
+ * <Particles className="absolute inset-0" quantity={80} />
12
+ */
13
+
14
+ interface MousePosition {
15
+ x: number;
16
+ y: number;
17
+ }
18
+
19
+ function useMousePosition(): MousePosition {
20
+ const [mousePosition, setMousePosition] = useState<MousePosition>({
21
+ x: 0,
22
+ y: 0,
23
+ });
24
+
25
+ useEffect(() => {
26
+ const handleMouseMove = (event: MouseEvent) => {
27
+ setMousePosition({ x: event.clientX, y: event.clientY });
28
+ };
29
+ window.addEventListener("mousemove", handleMouseMove);
30
+ return () => {
31
+ window.removeEventListener("mousemove", handleMouseMove);
32
+ };
33
+ }, []);
34
+
35
+ return mousePosition;
36
+ }
37
+
38
+ function hexToRgb(hex: string): number[] {
39
+ hex = hex.replace("#", "");
40
+ const hexInt = parseInt(hex, 16);
41
+ const red = (hexInt >> 16) & 255;
42
+ const green = (hexInt >> 8) & 255;
43
+ const blue = hexInt & 255;
44
+ return [red, green, blue];
45
+ }
46
+
47
+ interface ParticlesProps {
48
+ className?: string;
49
+ quantity?: number;
50
+ staticity?: number;
51
+ ease?: number;
52
+ size?: number;
53
+ refresh?: boolean;
54
+ /** Hex color for particles. Defaults to teal (#14b8a6). */
55
+ color?: string;
56
+ vx?: number;
57
+ vy?: number;
58
+ }
59
+
60
+ type Circle = {
61
+ x: number;
62
+ y: number;
63
+ translateX: number;
64
+ translateY: number;
65
+ size: number;
66
+ alpha: number;
67
+ targetAlpha: number;
68
+ dx: number;
69
+ dy: number;
70
+ magnetism: number;
71
+ };
72
+
73
+ export function Particles({
74
+ className = "",
75
+ quantity = 100,
76
+ staticity = 50,
77
+ ease = 50,
78
+ size = 0.4,
79
+ refresh = false,
80
+ color = "#14b8a6",
81
+ vx = 0,
82
+ vy = 0,
83
+ }: ParticlesProps) {
84
+ const canvasRef = useRef<HTMLCanvasElement>(null);
85
+ const canvasContainerRef = useRef<HTMLDivElement>(null);
86
+ const context = useRef<CanvasRenderingContext2D | null>(null);
87
+ const circles = useRef<Circle[]>([]);
88
+ const mousePosition = useMousePosition();
89
+ const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
90
+ const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
91
+ const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1;
92
+
93
+ const rgb = hexToRgb(color);
94
+
95
+ const circleParams = (): Circle => {
96
+ const x = Math.floor(Math.random() * canvasSize.current.w);
97
+ const y = Math.floor(Math.random() * canvasSize.current.h);
98
+ const pSize = Math.floor(Math.random() * 2) + size;
99
+ const alpha = 0;
100
+ const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1));
101
+ const dx = (Math.random() - 0.5) * 0.1;
102
+ const dy = (Math.random() - 0.5) * 0.1;
103
+ const magnetism = 0.1 + Math.random() * 4;
104
+ return {
105
+ x,
106
+ y,
107
+ translateX: 0,
108
+ translateY: 0,
109
+ size: pSize,
110
+ alpha,
111
+ targetAlpha,
112
+ dx,
113
+ dy,
114
+ magnetism,
115
+ };
116
+ };
117
+
118
+ const drawCircle = (circle: Circle, update = false) => {
119
+ if (context.current) {
120
+ const { x, y, translateX, translateY, size: s, alpha } = circle;
121
+ context.current.translate(translateX, translateY);
122
+ context.current.beginPath();
123
+ context.current.arc(x, y, s, 0, 2 * Math.PI);
124
+ context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`;
125
+ context.current.fill();
126
+ context.current.setTransform(dpr, 0, 0, dpr, 0, 0);
127
+ if (!update) {
128
+ circles.current.push(circle);
129
+ }
130
+ }
131
+ };
132
+
133
+ const clearContext = () => {
134
+ if (context.current) {
135
+ context.current.clearRect(
136
+ 0,
137
+ 0,
138
+ canvasSize.current.w,
139
+ canvasSize.current.h
140
+ );
141
+ }
142
+ };
143
+
144
+ const resizeCanvas = () => {
145
+ if (canvasContainerRef.current && canvasRef.current && context.current) {
146
+ circles.current.length = 0;
147
+ canvasSize.current.w = canvasContainerRef.current.offsetWidth;
148
+ canvasSize.current.h = canvasContainerRef.current.offsetHeight;
149
+ canvasRef.current.width = canvasSize.current.w * dpr;
150
+ canvasRef.current.height = canvasSize.current.h * dpr;
151
+ canvasRef.current.style.width = `${canvasSize.current.w}px`;
152
+ canvasRef.current.style.height = `${canvasSize.current.h}px`;
153
+ context.current.scale(dpr, dpr);
154
+ }
155
+ };
156
+
157
+ const drawParticles = () => {
158
+ clearContext();
159
+ for (let i = 0; i < quantity; i++) {
160
+ const circle = circleParams();
161
+ drawCircle(circle);
162
+ }
163
+ };
164
+
165
+ const remapValue = (
166
+ value: number,
167
+ start1: number,
168
+ end1: number,
169
+ start2: number,
170
+ end2: number
171
+ ): number => {
172
+ const remapped =
173
+ ((value - start1) * (end2 - start2)) / (end1 - start1) + start2;
174
+ return remapped > 0 ? remapped : 0;
175
+ };
176
+
177
+ const initCanvas = () => {
178
+ resizeCanvas();
179
+ drawParticles();
180
+ };
181
+
182
+ const onMouseMove = () => {
183
+ if (canvasRef.current) {
184
+ const rect = canvasRef.current.getBoundingClientRect();
185
+ const { w, h } = canvasSize.current;
186
+ const x = mousePosition.x - rect.left - w / 2;
187
+ const y = mousePosition.y - rect.top - h / 2;
188
+ const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2;
189
+ if (inside) {
190
+ mouse.current.x = x;
191
+ mouse.current.y = y;
192
+ }
193
+ }
194
+ };
195
+
196
+ const animate = () => {
197
+ clearContext();
198
+ circles.current.forEach((circle: Circle, i: number) => {
199
+ const edge = [
200
+ circle.x + circle.translateX - circle.size,
201
+ canvasSize.current.w - circle.x - circle.translateX - circle.size,
202
+ circle.y + circle.translateY - circle.size,
203
+ canvasSize.current.h - circle.y - circle.translateY - circle.size,
204
+ ];
205
+ const closestEdge = edge.reduce((a, b) => Math.min(a, b));
206
+ const remapClosestEdge = parseFloat(
207
+ remapValue(closestEdge, 0, 20, 0, 1).toFixed(2)
208
+ );
209
+ if (remapClosestEdge > 1) {
210
+ circle.alpha += 0.02;
211
+ if (circle.alpha > circle.targetAlpha) {
212
+ circle.alpha = circle.targetAlpha;
213
+ }
214
+ } else {
215
+ circle.alpha = circle.targetAlpha * remapClosestEdge;
216
+ }
217
+ circle.x += circle.dx + vx;
218
+ circle.y += circle.dy + vy;
219
+ circle.translateX +=
220
+ (mouse.current.x / (staticity / circle.magnetism) -
221
+ circle.translateX) /
222
+ ease;
223
+ circle.translateY +=
224
+ (mouse.current.y / (staticity / circle.magnetism) -
225
+ circle.translateY) /
226
+ ease;
227
+
228
+ drawCircle(circle, true);
229
+
230
+ if (
231
+ circle.x < -circle.size ||
232
+ circle.x > canvasSize.current.w + circle.size ||
233
+ circle.y < -circle.size ||
234
+ circle.y > canvasSize.current.h + circle.size
235
+ ) {
236
+ circles.current.splice(i, 1);
237
+ const newCircle = circleParams();
238
+ drawCircle(newCircle);
239
+ }
240
+ });
241
+ window.requestAnimationFrame(animate);
242
+ };
243
+
244
+ useEffect(() => {
245
+ if (canvasRef.current) {
246
+ context.current = canvasRef.current.getContext("2d");
247
+ }
248
+ initCanvas();
249
+ animate();
250
+ window.addEventListener("resize", initCanvas);
251
+ return () => {
252
+ window.removeEventListener("resize", initCanvas);
253
+ };
254
+ // eslint-disable-next-line react-hooks/exhaustive-deps
255
+ }, [color]);
256
+
257
+ // eslint-disable-next-line react-hooks/exhaustive-deps
258
+ useEffect(() => { onMouseMove(); }, [mousePosition.x, mousePosition.y]);
259
+
260
+ // eslint-disable-next-line react-hooks/exhaustive-deps
261
+ useEffect(() => { initCanvas(); }, [refresh]);
262
+
263
+ return (
264
+ <div className={className} ref={canvasContainerRef} aria-hidden="true">
265
+ <canvas ref={canvasRef} className="h-full w-full" />
266
+ </div>
267
+ );
268
+ }
@@ -0,0 +1,66 @@
1
+ interface ProofBandProps {
2
+ rating: number;
3
+ ratingCount: string;
4
+ highlights?: string[];
5
+ className?: string;
6
+ }
7
+
8
+ export function ProofBand({
9
+ rating,
10
+ ratingCount,
11
+ highlights = [],
12
+ className = "",
13
+ }: ProofBandProps) {
14
+ const fullStars = Math.floor(rating);
15
+ const hasHalf = rating - fullStars >= 0.3;
16
+
17
+ return (
18
+ <div className={`text-sm text-zinc-500 mb-8 ${className}`}>
19
+ {/* Stars + rating on its own line */}
20
+ <div className="flex items-center gap-2 mb-2">
21
+ <div className="flex items-center gap-0.5" aria-label={`${rating} out of 5`}>
22
+ {Array.from({ length: 5 }, (_, i) => (
23
+ <svg
24
+ key={i}
25
+ className={`w-4 h-4 ${
26
+ i < fullStars
27
+ ? "text-teal-500"
28
+ : i === fullStars && hasHalf
29
+ ? "text-teal-300"
30
+ : "text-zinc-200"
31
+ }`}
32
+ fill="currentColor"
33
+ viewBox="0 0 20 20"
34
+ aria-hidden="true"
35
+ >
36
+ <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" />
37
+ </svg>
38
+ ))}
39
+ </div>
40
+ <span className="text-zinc-700 font-medium">{rating.toFixed(1)}</span>
41
+ <span className="text-zinc-400">from {ratingCount}</span>
42
+ </div>
43
+
44
+ {/* Highlights as a clean vertical list */}
45
+ {highlights.length > 0 && (
46
+ <div className="flex flex-col gap-1">
47
+ {highlights.map((h, i) => (
48
+ <div key={i} className="flex items-center gap-2">
49
+ <svg
50
+ className="w-3.5 h-3.5 text-teal-500 flex-shrink-0"
51
+ fill="none"
52
+ viewBox="0 0 24 24"
53
+ stroke="currentColor"
54
+ strokeWidth={2.5}
55
+ aria-hidden="true"
56
+ >
57
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
58
+ </svg>
59
+ <span className="text-zinc-600">{h}</span>
60
+ </div>
61
+ ))}
62
+ </div>
63
+ )}
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import { motion } from "framer-motion";
4
+
5
+ interface ProofBannerProps {
6
+ quote: string;
7
+ source?: string;
8
+ metric: string;
9
+ }
10
+
11
+ export function ProofBanner({ quote, source, metric }: ProofBannerProps) {
12
+ return (
13
+ <motion.div
14
+ className="my-8 p-4 rounded-xl border border-zinc-200 bg-white flex items-start gap-4"
15
+ initial={{ opacity: 0, x: -16 }}
16
+ whileInView={{ opacity: 1, x: 0 }}
17
+ viewport={{ once: true }}
18
+ transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
19
+ >
20
+ <div className="shrink-0 text-center">
21
+ <span className="text-2xl font-bold text-teal-600">{metric}</span>
22
+ </div>
23
+ <div>
24
+ <p className="text-sm text-subtle-text italic">
25
+ &ldquo;{quote}&rdquo;
26
+ </p>
27
+ {source ? <p className="text-xs text-zinc-500 mt-1">{source}</p> : null}
28
+ </div>
29
+ </motion.div>
30
+ );
31
+ }