@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,216 @@
1
+ "use client";
2
+
3
+ import { Player } from "@remotion/player";
4
+ import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
5
+
6
+ /* ------------------------------------------------------------------ */
7
+ /* Built-in composition: "ConceptReveal" */
8
+ /* Dead simple: a stack of captions fading+sliding in, with a tinted */
9
+ /* gradient background and a growing progress bar. */
10
+ /* ------------------------------------------------------------------ */
11
+
12
+ interface ConceptRevealProps {
13
+ title: string;
14
+ subtitle?: string;
15
+ captions: string[];
16
+ accent?: "teal" | "cyan";
17
+ }
18
+
19
+ export function ConceptReveal({
20
+ title,
21
+ subtitle,
22
+ captions,
23
+ accent = "teal",
24
+ }: ConceptRevealProps) {
25
+ const frame = useCurrentFrame();
26
+ const { fps, durationInFrames } = useVideoConfig();
27
+
28
+ const titleOpacity = spring({ frame, fps, config: { damping: 200 } });
29
+ const titleY = interpolate(
30
+ spring({ frame, fps, config: { damping: 200 } }),
31
+ [0, 1],
32
+ [24, 0]
33
+ );
34
+
35
+ const subtitleProgress = spring({
36
+ frame: frame - 8,
37
+ fps,
38
+ config: { damping: 200 },
39
+ });
40
+
41
+ const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
42
+ extrapolateRight: "clamp",
43
+ });
44
+
45
+ const bgA = accent === "teal" ? "#14b8a6" : "#06b6d4";
46
+ const bgB = accent === "teal" ? "#0d9488" : "#0891b2";
47
+
48
+ return (
49
+ <AbsoluteFill
50
+ style={{
51
+ background: `linear-gradient(135deg, ${bgA} 0%, ${bgB} 100%)`,
52
+ fontFamily: "system-ui, -apple-system, sans-serif",
53
+ color: "white",
54
+ padding: 56,
55
+ }}
56
+ >
57
+ {/* Subtle grid overlay */}
58
+ <AbsoluteFill
59
+ style={{
60
+ backgroundImage:
61
+ "linear-gradient(rgba(255,255,255,0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.06) 1px, transparent 1px)",
62
+ backgroundSize: "48px 48px",
63
+ pointerEvents: "none",
64
+ }}
65
+ />
66
+
67
+ <div style={{ position: "relative", display: "flex", flexDirection: "column", height: "100%" }}>
68
+ <div
69
+ style={{
70
+ opacity: titleOpacity,
71
+ transform: `translateY(${titleY}px)`,
72
+ fontSize: 64,
73
+ fontWeight: 700,
74
+ lineHeight: 1.1,
75
+ letterSpacing: -1,
76
+ marginBottom: 16,
77
+ }}
78
+ >
79
+ {title}
80
+ </div>
81
+
82
+ {subtitle && (
83
+ <div
84
+ style={{
85
+ opacity: subtitleProgress * 0.85,
86
+ transform: `translateY(${interpolate(subtitleProgress, [0, 1], [20, 0])}px)`,
87
+ fontSize: 28,
88
+ fontWeight: 400,
89
+ lineHeight: 1.4,
90
+ marginBottom: 40,
91
+ }}
92
+ >
93
+ {subtitle}
94
+ </div>
95
+ )}
96
+
97
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", gap: 18 }}>
98
+ {captions.map((caption, i) => {
99
+ const captionStart = 18 + i * 14;
100
+ const captionProgress = spring({
101
+ frame: frame - captionStart,
102
+ fps,
103
+ config: { damping: 200, stiffness: 120 },
104
+ });
105
+ const opacity = captionProgress;
106
+ const x = interpolate(captionProgress, [0, 1], [-20, 0]);
107
+ return (
108
+ <div
109
+ key={i}
110
+ style={{
111
+ opacity,
112
+ transform: `translateX(${x}px)`,
113
+ fontSize: 32,
114
+ fontWeight: 500,
115
+ lineHeight: 1.35,
116
+ display: "flex",
117
+ alignItems: "center",
118
+ gap: 16,
119
+ }}
120
+ >
121
+ <div
122
+ style={{
123
+ width: 10,
124
+ height: 10,
125
+ borderRadius: "50%",
126
+ background: "white",
127
+ opacity: 0.9,
128
+ flexShrink: 0,
129
+ }}
130
+ />
131
+ {caption}
132
+ </div>
133
+ );
134
+ })}
135
+ </div>
136
+
137
+ {/* Progress bar */}
138
+ <div
139
+ style={{
140
+ marginTop: 40,
141
+ height: 4,
142
+ background: "rgba(255,255,255,0.2)",
143
+ borderRadius: 2,
144
+ overflow: "hidden",
145
+ }}
146
+ >
147
+ <div
148
+ style={{
149
+ width: `${progress * 100}%`,
150
+ height: "100%",
151
+ background: "white",
152
+ }}
153
+ />
154
+ </div>
155
+ </div>
156
+ </AbsoluteFill>
157
+ );
158
+ }
159
+
160
+ /* ------------------------------------------------------------------ */
161
+ /* Public wrapper */
162
+ /* ------------------------------------------------------------------ */
163
+
164
+ interface RemotionClipProps {
165
+ title: string;
166
+ subtitle?: string;
167
+ captions: string[];
168
+ accent?: "teal" | "cyan";
169
+ durationInFrames?: number;
170
+ fps?: number;
171
+ width?: number;
172
+ height?: number;
173
+ autoPlay?: boolean;
174
+ loop?: boolean;
175
+ className?: string;
176
+ }
177
+
178
+ /**
179
+ * Remotion-powered video clip rendered in the browser via @remotion/player.
180
+ * Uses the built-in ConceptReveal composition. Pass a title, optional subtitle,
181
+ * and 3-6 captions; the clip animates them with spring physics on a gradient
182
+ * background. No video file, everything is code-generated.
183
+ */
184
+ export function RemotionClip({
185
+ title,
186
+ subtitle,
187
+ captions,
188
+ accent = "teal",
189
+ durationInFrames = 150,
190
+ fps = 30,
191
+ width = 1280,
192
+ height = 720,
193
+ autoPlay = true,
194
+ loop = true,
195
+ className = "",
196
+ }: RemotionClipProps) {
197
+ return (
198
+ <div
199
+ className={`my-10 rounded-2xl overflow-hidden border border-zinc-200 shadow-lg ${className}`}
200
+ style={{ aspectRatio: `${width} / ${height}` }}
201
+ >
202
+ <Player
203
+ component={ConceptReveal}
204
+ durationInFrames={durationInFrames}
205
+ compositionWidth={width}
206
+ compositionHeight={height}
207
+ fps={fps}
208
+ autoPlay={autoPlay}
209
+ loop={loop}
210
+ controls
211
+ style={{ width: "100%", height: "100%" }}
212
+ inputProps={{ title, subtitle, captions, accent }}
213
+ />
214
+ </div>
215
+ );
216
+ }
@@ -0,0 +1,144 @@
1
+ "use client";
2
+
3
+ import { motion } from "framer-motion";
4
+
5
+ interface SequenceMessage {
6
+ from: number;
7
+ to: number;
8
+ label: string;
9
+ type?: "request" | "response" | "event" | "error";
10
+ }
11
+
12
+ interface SequenceDiagramProps {
13
+ title: string;
14
+ actors: string[];
15
+ messages: SequenceMessage[];
16
+ }
17
+
18
+ const typeStyles: Record<string, { stroke: string; text: string; dash?: string }> = {
19
+ request: { stroke: "stroke-emerald-500", text: "text-emerald-500" },
20
+ response: { stroke: "stroke-teal-500", text: "text-teal-600", dash: "6 3" },
21
+ event: { stroke: "stroke-amber-400", text: "text-amber-400" },
22
+ error: { stroke: "stroke-red-400", text: "text-red-400", dash: "4 4" },
23
+ };
24
+
25
+ export function SequenceDiagram({ title, actors, messages }: SequenceDiagramProps) {
26
+ const colWidth = 160;
27
+ const rowHeight = 48;
28
+ const headerHeight = 60;
29
+ const svgWidth = colWidth * actors.length;
30
+ const svgHeight = headerHeight + rowHeight * messages.length + 32;
31
+
32
+ return (
33
+ <motion.div
34
+ className="my-10 rounded-2xl border border-zinc-200 bg-white p-6 overflow-x-auto"
35
+ initial={{ opacity: 0, y: 16 }}
36
+ whileInView={{ opacity: 1, y: 0 }}
37
+ viewport={{ once: true }}
38
+ transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
39
+ >
40
+ <p className="text-xs font-mono uppercase tracking-widest text-emerald-500 mb-4">
41
+ {title}
42
+ </p>
43
+ <svg
44
+ viewBox={`0 0 ${svgWidth} ${svgHeight}`}
45
+ width={svgWidth}
46
+ height={svgHeight}
47
+ className="max-w-full"
48
+ >
49
+ {/* Actor headers */}
50
+ {actors.map((actor, i) => {
51
+ const cx = colWidth * i + colWidth / 2;
52
+ return (
53
+ <motion.g
54
+ key={`actor-${i}`}
55
+ initial={{ opacity: 0, y: -8 }}
56
+ whileInView={{ opacity: 1, y: 0 }}
57
+ viewport={{ once: true }}
58
+ transition={{ delay: i * 0.06, duration: 0.3 }}
59
+ >
60
+ <rect
61
+ x={cx - 56}
62
+ y={8}
63
+ width={112}
64
+ height={32}
65
+ rx={8}
66
+ className="fill-white stroke-zinc-200"
67
+ strokeWidth={1}
68
+ />
69
+ <text
70
+ x={cx}
71
+ y={29}
72
+ textAnchor="middle"
73
+ className="fill-zinc-900 text-[11px] font-medium"
74
+ fontFamily="ui-monospace, monospace"
75
+ >
76
+ {actor}
77
+ </text>
78
+ {/* Lifeline */}
79
+ <line
80
+ x1={cx}
81
+ y1={44}
82
+ x2={cx}
83
+ y2={svgHeight - 8}
84
+ className="stroke-zinc-200"
85
+ strokeWidth={1}
86
+ strokeDasharray="3 3"
87
+ />
88
+ </motion.g>
89
+ );
90
+ })}
91
+
92
+ {/* Messages */}
93
+ {messages.map((msg, i) => {
94
+ const y = headerHeight + rowHeight * i + rowHeight / 2;
95
+ const fromX = colWidth * msg.from + colWidth / 2;
96
+ const toX = colWidth * msg.to + colWidth / 2;
97
+ const isLeft = toX < fromX;
98
+ const style = typeStyles[msg.type || "request"];
99
+ const arrowX = toX + (isLeft ? 6 : -6);
100
+
101
+ return (
102
+ <motion.g
103
+ key={`msg-${i}`}
104
+ initial={{ opacity: 0 }}
105
+ whileInView={{ opacity: 1 }}
106
+ viewport={{ once: true }}
107
+ transition={{ delay: 0.3 + i * 0.1, duration: 0.3 }}
108
+ >
109
+ <line
110
+ x1={fromX}
111
+ y1={y}
112
+ x2={toX}
113
+ y2={y}
114
+ className={style.stroke}
115
+ strokeWidth={1.5}
116
+ strokeDasharray={style.dash}
117
+ markerEnd=""
118
+ />
119
+ {/* Arrowhead */}
120
+ <polygon
121
+ points={
122
+ isLeft
123
+ ? `${arrowX},${y} ${arrowX + 7},${y - 4} ${arrowX + 7},${y + 4}`
124
+ : `${arrowX},${y} ${arrowX - 7},${y - 4} ${arrowX - 7},${y + 4}`
125
+ }
126
+ className={style.stroke.replace("stroke-", "fill-")}
127
+ />
128
+ {/* Label */}
129
+ <text
130
+ x={(fromX + toX) / 2}
131
+ y={y - 8}
132
+ textAnchor="middle"
133
+ className={`${style.text} text-[10px]`}
134
+ fontFamily="ui-monospace, monospace"
135
+ >
136
+ {msg.label}
137
+ </text>
138
+ </motion.g>
139
+ );
140
+ })}
141
+ </svg>
142
+ </motion.div>
143
+ );
144
+ }
@@ -0,0 +1,51 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+
5
+ interface ShimmerButtonProps {
6
+ children: React.ReactNode;
7
+ href?: string;
8
+ onClick?: () => void;
9
+ className?: string;
10
+ }
11
+
12
+ /**
13
+ * Magic UI style shimmer button. A continuous light shimmer sweeps
14
+ * across the button surface. Uses plain CSS keyframes with a
15
+ * diagonal linear-gradient mask so it works without extra deps.
16
+ */
17
+ export function ShimmerButton({
18
+ children,
19
+ href,
20
+ onClick,
21
+ className = "",
22
+ }: ShimmerButtonProps) {
23
+ const inner = (
24
+ <span
25
+ className={`relative inline-flex items-center justify-center gap-2 overflow-hidden rounded-full bg-gradient-to-br from-cyan-500 to-teal-600 px-8 py-3 text-sm font-semibold text-white shadow-lg shadow-teal-500/30 transition-transform hover:scale-[1.02] ${className}`}
26
+ >
27
+ <span className="pointer-events-none absolute inset-0 -translate-x-full animate-[shimmer_2.2s_infinite] bg-gradient-to-r from-transparent via-white/40 to-transparent" />
28
+ <span className="relative z-10 flex items-center gap-2">{children}</span>
29
+ <style>{`
30
+ @keyframes shimmer {
31
+ 0% { transform: translateX(-100%); }
32
+ 100% { transform: translateX(200%); }
33
+ }
34
+ `}</style>
35
+ </span>
36
+ );
37
+
38
+ if (href) {
39
+ return (
40
+ <Link href={href} className="inline-block">
41
+ {inner}
42
+ </Link>
43
+ );
44
+ }
45
+
46
+ return (
47
+ <button type="button" onClick={onClick} className="inline-block">
48
+ {inner}
49
+ </button>
50
+ );
51
+ }
@@ -0,0 +1,87 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ /**
6
+ * ShineBorder: wraps children in a container with an animated radial-gradient
7
+ * border that rotates continuously, creating a "shine" effect. Defaults to a
8
+ * teal glow. Use it around cards, CTAs, or featured sections.
9
+ *
10
+ * Requires the `shine-pulse` keyframe in your Tailwind config:
11
+ * ```
12
+ * keyframes: {
13
+ * "shine-pulse": {
14
+ * "0%": { "background-position": "0% 0%" },
15
+ * "50%": { "background-position": "100% 100%" },
16
+ * to: { "background-position": "0% 0%" },
17
+ * },
18
+ * }
19
+ * ```
20
+ *
21
+ * @example
22
+ * <ShineBorder color="#14b8a6">
23
+ * <p>Featured content</p>
24
+ * </ShineBorder>
25
+ */
26
+
27
+ type TColorProp = `#${string}` | `#${string}`[];
28
+
29
+ interface ShineBorderProps {
30
+ borderRadius?: number;
31
+ borderWidth?: number;
32
+ duration?: number;
33
+ /** Hex color(s) for the shine gradient. Defaults to teal. */
34
+ color?: TColorProp;
35
+ className?: string;
36
+ children: React.ReactNode;
37
+ }
38
+
39
+ export function ShineBorder({
40
+ borderRadius = 8,
41
+ borderWidth = 1,
42
+ duration = 14,
43
+ color = "#14b8a6",
44
+ className,
45
+ children,
46
+ }: ShineBorderProps) {
47
+ const outerBase =
48
+ "relative grid min-h-[60px] w-fit min-w-[300px] place-items-center bg-white p-3 text-zinc-900";
49
+ const outerClass = className
50
+ ? `${outerBase} ${className}`
51
+ : outerBase;
52
+
53
+ return (
54
+ <div
55
+ style={
56
+ {
57
+ "--border-radius": `${borderRadius}px`,
58
+ borderRadius: `${borderRadius}px`,
59
+ } as React.CSSProperties
60
+ }
61
+ className={outerClass}
62
+ >
63
+ <div
64
+ style={
65
+ {
66
+ "--border-width": `${borderWidth}px`,
67
+ "--border-radius": `${borderRadius}px`,
68
+ "--border-radius-child": `${borderRadius * 0.2}px`,
69
+ "--shine-pulse-duration": `${duration}s`,
70
+ "--mask-linear-gradient":
71
+ "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
72
+ "--background-radial-gradient": `radial-gradient(transparent,transparent, ${
73
+ Array.isArray(color) ? color.join(",") : color
74
+ },transparent,transparent)`,
75
+ } as React.CSSProperties
76
+ }
77
+ className="before:bg-shine-size before:absolute before:inset-[0] before:aspect-square before:h-full before:w-full before:rounded-[--border-radius] before:p-[--border-width] before:will-change-[background-position] before:content-[''] before:![-webkit-mask-composite:xor] before:![mask-composite:exclude] before:[background-image:var(--background-radial-gradient)] before:[background-size:300%_300%] before:[mask:var(--mask-linear-gradient)] motion-safe:before:animate-[shine-pulse_var(--shine-pulse-duration)_infinite_linear]"
78
+ />
79
+ <div
80
+ className="z-[1] h-full w-full"
81
+ style={{ borderRadius: `${borderRadius * 0.2}px` }}
82
+ >
83
+ {children}
84
+ </div>
85
+ </div>
86
+ );
87
+ }
@@ -0,0 +1,108 @@
1
+ "use client";
2
+
3
+ import { motion } from "framer-motion";
4
+
5
+ interface TimelineStep {
6
+ title: string;
7
+ description: string;
8
+ icon?: string;
9
+ detail?: React.ReactNode;
10
+ }
11
+
12
+ interface StepTimelineProps {
13
+ title?: string;
14
+ steps: TimelineStep[];
15
+ className?: string;
16
+ }
17
+
18
+ /**
19
+ * Vertical timeline with animated step reveals.
20
+ * Each step connects via an animated line that draws downward.
21
+ */
22
+ export function StepTimeline({
23
+ title,
24
+ steps,
25
+ className = "",
26
+ }: StepTimelineProps) {
27
+ return (
28
+ <div className={`my-10 ${className}`}>
29
+ {title && (
30
+ <motion.h3
31
+ initial={{ opacity: 0, y: 12 }}
32
+ whileInView={{ opacity: 1, y: 0 }}
33
+ viewport={{ once: true }}
34
+ transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
35
+ className="text-lg font-semibold text-zinc-900 mb-6"
36
+ >
37
+ {title}
38
+ </motion.h3>
39
+ )}
40
+
41
+ <div className="relative pl-8">
42
+ {/* Vertical line */}
43
+ <motion.div
44
+ initial={{ scaleY: 0 }}
45
+ whileInView={{ scaleY: 1 }}
46
+ viewport={{ once: true, margin: "-40px" }}
47
+ transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
48
+ className="absolute left-3 top-2 bottom-2 w-px bg-gradient-to-b from-teal-400 via-teal-300 to-zinc-200 origin-top"
49
+ />
50
+
51
+ <div className="space-y-8">
52
+ {steps.map((step, i) => (
53
+ <motion.div
54
+ key={i}
55
+ initial={{ opacity: 0, x: -16 }}
56
+ whileInView={{ opacity: 1, x: 0 }}
57
+ viewport={{ once: true, margin: "-20px" }}
58
+ transition={{
59
+ duration: 0.45,
60
+ ease: [0.16, 1, 0.3, 1],
61
+ delay: i * 0.1,
62
+ }}
63
+ className="relative"
64
+ >
65
+ {/* Dot */}
66
+ <motion.div
67
+ initial={{ scale: 0 }}
68
+ whileInView={{ scale: 1 }}
69
+ viewport={{ once: true }}
70
+ transition={{
71
+ duration: 0.3,
72
+ delay: i * 0.1 + 0.15,
73
+ type: "spring",
74
+ stiffness: 300,
75
+ damping: 20,
76
+ }}
77
+ className="absolute -left-8 top-1 w-6 h-6 rounded-full bg-teal-50 border-2 border-teal-400 flex items-center justify-center"
78
+ >
79
+ {step.icon ? (
80
+ <span className="text-xs">{step.icon}</span>
81
+ ) : (
82
+ <span className="text-[10px] font-mono font-bold text-teal-600">
83
+ {i + 1}
84
+ </span>
85
+ )}
86
+ </motion.div>
87
+
88
+ {/* Content */}
89
+ <div>
90
+ <h4 className="text-sm font-semibold text-zinc-900 mb-1">
91
+ {step.title}
92
+ </h4>
93
+ <p className="text-sm text-zinc-500 leading-relaxed">
94
+ {step.description}
95
+ </p>
96
+ {step.detail && (
97
+ <div className="mt-3 rounded-lg border border-zinc-100 bg-zinc-50 p-3 text-sm">
98
+ {step.detail}
99
+ </div>
100
+ )}
101
+ </div>
102
+ </motion.div>
103
+ ))}
104
+ </div>
105
+ </div>
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+
6
+ interface StickyBottomCtaProps {
7
+ description: string;
8
+ buttonLabel: string;
9
+ href: string;
10
+ scrollThreshold?: number;
11
+ }
12
+
13
+ export function StickyBottomCta({
14
+ description,
15
+ buttonLabel,
16
+ href,
17
+ scrollThreshold = 600,
18
+ }: StickyBottomCtaProps) {
19
+ const [visible, setVisible] = useState(false);
20
+
21
+ useEffect(() => {
22
+ const handler = () => setVisible(window.scrollY > scrollThreshold);
23
+ window.addEventListener("scroll", handler);
24
+ return () => window.removeEventListener("scroll", handler);
25
+ }, [scrollThreshold]);
26
+
27
+ return (
28
+ <AnimatePresence>
29
+ {visible && (
30
+ <motion.div
31
+ initial={{ y: "100%" }}
32
+ animate={{ y: 0 }}
33
+ exit={{ y: "100%" }}
34
+ transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
35
+ className="fixed bottom-0 left-0 right-0 z-40 border-t border-zinc-200 backdrop-blur-xl py-3 px-6"
36
+ style={{ backgroundColor: "var(--mobile-bg)" }}
37
+ >
38
+ <div className="mx-auto max-w-7xl flex items-center justify-between">
39
+ <p className="text-sm text-zinc-500 hidden sm:block">
40
+ {description}
41
+ </p>
42
+ <a
43
+ href={href}
44
+ className="text-sm font-medium px-4 py-2 rounded-lg bg-teal-500 text-accent-contrast hover:bg-accent-dim transition-colors"
45
+ >
46
+ {buttonLabel}
47
+ </a>
48
+ </div>
49
+ </motion.div>
50
+ )}
51
+ </AnimatePresence>
52
+ );
53
+ }