Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 1m2s
Build & Deploy / 🏗️ Build (push) Failing after 3m44s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
125 lines
3.4 KiB
TypeScript
125 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { useInView } from "framer-motion";
|
|
|
|
/**
|
|
* GlitchText: Binary scramble reveal effect
|
|
*
|
|
* Text characters scramble through random binary (0/1) before settling
|
|
* into the final text. Creates a "decoding" feel.
|
|
*
|
|
* Can also be triggered on hover for buttons.
|
|
*/
|
|
|
|
interface GlitchTextProps {
|
|
children: string;
|
|
className?: string;
|
|
delay?: number;
|
|
duration?: number;
|
|
trigger?: "inView" | "hover" | "mount";
|
|
as?: "span" | "div" | "h1" | "h2" | "p";
|
|
}
|
|
|
|
const CHARS = "01";
|
|
|
|
export const GlitchText: React.FC<GlitchTextProps> = ({
|
|
children,
|
|
className = "",
|
|
delay = 0,
|
|
duration = 0.8,
|
|
trigger = "inView",
|
|
as: Tag = "span",
|
|
}) => {
|
|
const text = children;
|
|
const [display, setDisplay] = React.useState(text);
|
|
const [isRevealed, setIsRevealed] = React.useState(false);
|
|
const [isHovering, setIsHovering] = React.useState(false);
|
|
const ref = React.useRef<any>(null);
|
|
const isInView = useInView(ref, { once: true, margin: "-10%" });
|
|
|
|
// Initial scramble reveal (on mount or in-view)
|
|
React.useEffect(() => {
|
|
if (isRevealed) return;
|
|
|
|
const shouldStart =
|
|
trigger === "mount" || (trigger === "inView" && isInView);
|
|
if (!shouldStart) return;
|
|
|
|
const chars = text.split("");
|
|
const totalFrames = Math.ceil(duration * 60); // ~60fps
|
|
const staggerPerChar = totalFrames / chars.length;
|
|
let frame = 0;
|
|
|
|
const delayMs = delay * 1000;
|
|
|
|
const timeout = setTimeout(() => {
|
|
const interval = setInterval(() => {
|
|
frame++;
|
|
const revealed = chars.map((char, i) => {
|
|
if (char === " ") return " ";
|
|
const charFrame = i * staggerPerChar;
|
|
if (frame > charFrame + staggerPerChar * 2) return char; // Settled
|
|
if (frame > charFrame) {
|
|
// Scrambling phase
|
|
return CHARS[Math.floor(Math.random() * CHARS.length)];
|
|
}
|
|
return CHARS[Math.floor(Math.random() * CHARS.length)];
|
|
});
|
|
setDisplay(revealed.join(""));
|
|
|
|
if (frame >= totalFrames + staggerPerChar * 2) {
|
|
setDisplay(text);
|
|
setIsRevealed(true);
|
|
clearInterval(interval);
|
|
}
|
|
}, 1000 / 60);
|
|
|
|
return () => clearInterval(interval);
|
|
}, delayMs);
|
|
|
|
return () => clearTimeout(timeout);
|
|
}, [isInView, trigger, text, delay, duration, isRevealed]);
|
|
|
|
// Hover re-scramble (for buttons)
|
|
React.useEffect(() => {
|
|
if (!isHovering || trigger !== "hover") return;
|
|
|
|
const chars = text.split("");
|
|
const totalFrames = 20; // Quick scramble
|
|
let frame = 0;
|
|
|
|
const interval = setInterval(() => {
|
|
frame++;
|
|
const scrambled = chars.map((char, i) => {
|
|
if (char === " ") return " ";
|
|
const settle = (frame / totalFrames) * chars.length;
|
|
if (i < settle) return char;
|
|
return CHARS[Math.floor(Math.random() * CHARS.length)];
|
|
});
|
|
setDisplay(scrambled.join(""));
|
|
|
|
if (frame >= totalFrames) {
|
|
setDisplay(text);
|
|
clearInterval(interval);
|
|
}
|
|
}, 1000 / 60);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [isHovering, trigger, text]);
|
|
|
|
return (
|
|
<Tag
|
|
ref={ref}
|
|
className={`${className}`}
|
|
onMouseEnter={trigger === "hover" ? () => setIsHovering(true) : undefined}
|
|
onMouseLeave={
|
|
trigger === "hover" ? () => setIsHovering(false) : undefined
|
|
}
|
|
style={{ fontVariantNumeric: "tabular-nums" }} // Prevent layout shift during scramble
|
|
>
|
|
{display}
|
|
</Tag>
|
|
);
|
|
};
|