@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
|
@@ -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>→</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
|
+
“{quote}”
|
|
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
|
+
}
|