Submatter/apps/web/components/marketing/scramble-text.tsx

65 lines
1.6 KiB
TypeScript
Raw Normal View History

"use client";
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
const CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_-";
type ScrambleTextProps = {
text: string;
className?: string;
durationMs?: number;
};
export function ScrambleText({ text, className, durationMs = 1200 }: ScrambleTextProps) {
const reduceMotion = useReducedMotion();
const [display, setDisplay] = useState(text);
useEffect(() => {
if (reduceMotion) {
setDisplay(text);
return;
}
let frame = 0;
const totalFrames = Math.ceil(durationMs / 32);
const locked = new Set<number>();
const id = window.setInterval(() => {
frame += 1;
const progress = frame / totalFrames;
for (let i = 0; i < text.length; i++) {
if (text[i] === " ") continue;
if (Math.random() < progress * 0.15 + 0.05) locked.add(i);
}
const next = text
.split("")
.map((char, i) => {
if (char === " ") return " ";
if (locked.has(i) || progress > 0.92) return char;
return CHARSET[Math.floor(Math.random() * CHARSET.length)] ?? char;
})
.join("");
setDisplay(next);
if (frame >= totalFrames) {
window.clearInterval(id);
setDisplay(text);
}
}, 32);
return () => window.clearInterval(id);
}, [text, durationMs, reduceMotion]);
return (
<motion.span className={cn("inline-block", className)} initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{display}
</motion.span>
);
}