Files
mintel.me/apps/web/src/components/GlitchText.tsx
Marc Mintel c1295546a6
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
feat: unify code-like components with shared CodeWindow, fix blog re-render loop, and stabilize layouts
2026-02-15 17:34:07 +01:00

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>
);
};