65 lines
1.6 KiB
TypeScript
65 lines
1.6 KiB
TypeScript
|
|
"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>
|
||
|
|
);
|
||
|
|
}
|