Hero: replace particle field with cobe globe (Capricorn view, multi-axis drag, reduced-motion safe), telemetry HUD with pointer parallax, particle text build effect, Space Grotesk display font. SEO: short titles with template, PT-BR accents, twitter cards on dynamic cases, escaped JSON-LD via lib/jsonld.ts, CSP report-only, 25 service FAQs answer-first. Design: fix TW4 layered reset killing spacing utilities, literal @theme tokens, mobile hero overflow, header overlap, empty case cards. Remove framer-motion (unified on motion/react), header version badge, phone number from hero CTA. Production build verified (24 static pages).
120 lines
2.8 KiB
TypeScript
120 lines
2.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
interface CinematicTextProps {
|
|
text: string;
|
|
className?: string;
|
|
tag?: "h1" | "h2" | "h3" | "p" | "span";
|
|
delay?: number;
|
|
splitBy?: "chars" | "words" | "lines";
|
|
}
|
|
|
|
export default function CinematicText({
|
|
text,
|
|
className = "",
|
|
tag: Tag = "h2",
|
|
delay = 0,
|
|
splitBy = "chars",
|
|
}: CinematicTextProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
|
|
let cleanup: (() => void) | undefined;
|
|
|
|
const setup = async () => {
|
|
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
|
import("gsap"),
|
|
import("gsap/ScrollTrigger"),
|
|
]);
|
|
|
|
gsap.registerPlugin(ScrollTrigger);
|
|
|
|
const spans = el.querySelectorAll("[data-char]");
|
|
if (spans.length === 0) return;
|
|
|
|
gsap.set(spans, {
|
|
opacity: 0,
|
|
y: 60,
|
|
rotateX: -90,
|
|
transformOrigin: "center bottom",
|
|
transformPerspective: 600,
|
|
});
|
|
|
|
const tl = gsap.timeline({
|
|
scrollTrigger: {
|
|
trigger: el,
|
|
start: "top 80%",
|
|
toggleActions: "play none none none",
|
|
},
|
|
});
|
|
|
|
tl.to(spans, {
|
|
opacity: 1,
|
|
y: 0,
|
|
rotateX: 0,
|
|
duration: 0.8,
|
|
ease: "back.out(1.2)",
|
|
stagger: splitBy === "chars" ? 0.02 : splitBy === "words" ? 0.06 : 0.12,
|
|
delay,
|
|
});
|
|
|
|
cleanup = () => {
|
|
tl.scrollTrigger?.kill();
|
|
tl.kill();
|
|
};
|
|
};
|
|
|
|
void setup();
|
|
|
|
return () => {
|
|
cleanup?.();
|
|
};
|
|
}, [text, delay, splitBy]);
|
|
|
|
const renderContent = () => {
|
|
if (splitBy === "chars") {
|
|
return text.split("").map((char, i) => (
|
|
<span
|
|
key={`${char}-${i}`}
|
|
data-char
|
|
className="inline-block will-change-transform"
|
|
style={{ whiteSpace: char === " " ? "pre" : undefined }}
|
|
>
|
|
{char === " " ? "\u00A0" : char}
|
|
</span>
|
|
));
|
|
}
|
|
|
|
if (splitBy === "words") {
|
|
return text.split(" ").map((word, i) => (
|
|
<span key={`${word}-${i}`} className="inline-block overflow-hidden">
|
|
<span data-char className="inline-block will-change-transform">
|
|
{word}
|
|
</span>
|
|
{i < text.split(" ").length - 1 && "\u00A0"}
|
|
</span>
|
|
));
|
|
}
|
|
|
|
// lines
|
|
return text.split("\n").map((line, i) => (
|
|
<span key={`${line}-${i}`} className="block overflow-hidden">
|
|
<span data-char className="block will-change-transform">
|
|
{line}
|
|
</span>
|
|
</span>
|
|
));
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="overflow-hidden">
|
|
<Tag className={className}>{renderContent()}</Tag>
|
|
</div>
|
|
);
|
|
}
|