since3-website/modules/shared/ui/components/scramble-link.tsx
2026-09-25 00:45:33 +02:00

62 lines
1.6 KiB
TypeScript

"use client"
import { useRef } from "react"
import { cn } from "@/lib/utils"
const GLYPHS = "!<>-_\\/[]{}=+*^?#01"
const DURATION_MS = 420
/** Link whose label decodes itself on hover, like terminal output. */
export function ScrambleLink({
href,
children,
className,
onClick,
}: {
href: string
children: string
className?: string
onClick?: () => void
}) {
const textRef = useRef<HTMLSpanElement>(null)
const frameRef = useRef(0)
const scramble = () => {
const el = textRef.current
if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
cancelAnimationFrame(frameRef.current)
const start = performance.now()
const tick = (now: number) => {
const progress = Math.min((now - start) / DURATION_MS, 1)
const revealed = Math.floor(progress * children.length)
el.textContent = children
.split("")
.map((char, index) =>
index < revealed || char === " "
? char
: GLYPHS[Math.floor(Math.random() * GLYPHS.length)]
)
.join("")
if (progress < 1) frameRef.current = requestAnimationFrame(tick)
}
frameRef.current = requestAnimationFrame(tick)
}
return (
<a
href={href}
onPointerEnter={scramble}
onFocus={scramble}
onClick={onClick}
aria-label={children}
className={cn("inline-block", className)}
>
{/* Fixed width per character so the scramble never shifts the layout */}
<span ref={textRef} aria-hidden="true" className="font-mono">
{children}
</span>
</a>
)
}