"use client"; import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react"; import { useGSAP } from "@gsap/react"; import gsap from "gsap"; import { cn } from "@/lib/utils"; gsap.registerPlugin(useGSAP); export type AsciiHoverColor = | "default" | "emerald" | "blue" | "violet" | "amber" | "rose" | "cyan"; export interface AsciiColorPalette { dark: { text: string; glow: string; }; light: { text: string; glow: string; }; } const COLOR_PALETTES: Record = { default: { dark: { text: "#ffffff", glow: "rgba(255, 255, 255, 0.4)", }, light: { text: "#09090b", glow: "rgba(0, 0, 0, 0.12)", }, }, emerald: { dark: { text: "#34d399", glow: "rgba(52, 211, 153, 0.45)", }, light: { text: "#059669", glow: "rgba(5, 150, 105, 0.25)", }, }, blue: { dark: { text: "#60a5fa", glow: "rgba(96, 165, 250, 0.45)", }, light: { text: "#2563eb", glow: "rgba(37, 99, 235, 0.25)", }, }, violet: { dark: { text: "#a78bfa", glow: "rgba(167, 139, 250, 0.45)", }, light: { text: "#7c3aed", glow: "rgba(124, 58, 237, 0.25)", }, }, cyan: { dark: { text: "#38bdf8", glow: "rgba(56, 189, 248, 0.45)", }, light: { text: "#0284c7", glow: "rgba(2, 132, 199, 0.25)", }, }, amber: { dark: { text: "#fbbf24", glow: "rgba(251, 191, 36, 0.45)", }, light: { text: "#d97706", glow: "rgba(217, 119, 6, 0.25)", }, }, rose: { dark: { text: "#fb7185", glow: "rgba(251, 113, 133, 0.45)", }, light: { text: "#e11d48", glow: "rgba(225, 29, 72, 0.25)", }, }, }; const DEFAULT_ASCII_CHARS = "!<>-_\\/[]{}—=+*^?#_$%&~:;0123456789"; export interface AsciiGlitchTextHandle { trigger: () => void; reset: () => void; } export interface AsciiGlitchTextProps { text: string; isHovered?: boolean; active?: boolean; color?: AsciiHoverColor | string; duration?: number; asciiChars?: string; className?: string; } export const AsciiGlitchText = React.forwardRef< AsciiGlitchTextHandle, AsciiGlitchTextProps >( ( { text, isHovered = false, active = false, color = "default", duration = 0.75, asciiChars = DEFAULT_ASCII_CHARS, className, }, ref, ) => { const [displayChars, setDisplayChars] = useState(() => text.split(""), ); const [prevText, setPrevText] = useState(text); const progressRef = useRef({ progress: 0 }); const lastUpdateRef = useRef(0); const tweenRef = useRef(null); if (prevText !== text) { setPrevText(text); setDisplayChars(text.split("")); } const isPresetColor = color in COLOR_PALETTES; const isCustomColor = !isPresetColor && Boolean(color); const triggerGlitch = useCallback(() => { if (typeof window === "undefined") return; if (tweenRef.current) { tweenRef.current.kill(); } const reduceMotion = window.matchMedia( "(prefers-reduced-motion: reduce)", ).matches; if (reduceMotion) { setDisplayChars(text.split("")); return; } progressRef.current.progress = 0; lastUpdateRef.current = 0; const chars = text.split(""); const total = chars.length; const glyphs = asciiChars || DEFAULT_ASCII_CHARS; tweenRef.current = gsap.to(progressRef.current, { progress: 1, duration, ease: "power2.out", onUpdate: () => { const now = performance.now(); const p = progressRef.current.progress; const shouldRegenerate = now - lastUpdateRef.current >= 45 || p >= 0.98; if (!shouldRegenerate && p < 0.98) return; lastUpdateRef.current = now; const updated = chars.map((char, index) => { if (char === " ") return " "; const threshold = (index + 0.6) / (total + 0.4); if (p >= threshold) { return char; } const randomIndex = Math.floor(Math.random() * glyphs.length); return glyphs[randomIndex]; }); setDisplayChars(updated); }, onComplete: () => { setDisplayChars(chars); }, }); }, [text, duration, asciiChars]); const resetGlitch = useCallback(() => { if (tweenRef.current) { tweenRef.current.kill(); } setDisplayChars(text.split("")); }, [text]); useImperativeHandle( ref, () => ({ trigger: triggerGlitch, reset: resetGlitch, }), [triggerGlitch, resetGlitch], ); useEffect(() => { if (isHovered) { const frame = requestAnimationFrame(() => { triggerGlitch(); }); return () => cancelAnimationFrame(frame); } }, [isHovered, triggerGlitch]); useEffect(() => { return () => { if (tweenRef.current) { tweenRef.current.kill(); } }; }, []); const highlighted = isHovered || active; const customColorStyle = useMemo(() => { if (!highlighted) return undefined; if (isCustomColor) { return { color, textShadow: `0 0 10px ${color}80`, }; } return undefined; }, [highlighted, isCustomColor, color]); const originalChars = useMemo(() => text.split(""), [text]); return ( {displayChars.map((char, index) => { const isGlitching = char !== originalChars[index]; return ( {char === " " ? "\u00A0" : char} ); })} ); }, ); AsciiGlitchText.displayName = "AsciiGlitchText"; export type AsciiHoverButtonVariant = | "segmented" | "card" | "outline" | "ghost"; export type AsciiHoverButtonSize = "sm" | "default" | "lg"; export interface AsciiHoverButtonProps extends Omit< React.ButtonHTMLAttributes, "color" > { text?: string; variant?: AsciiHoverButtonVariant; size?: AsciiHoverButtonSize; color?: AsciiHoverColor | string; active?: boolean; asciiChars?: string; scrambleDuration?: number; leftIcon?: React.ReactNode; rightIcon?: React.ReactNode; } const sizeStyles: Record = { sm: "h-9 px-3.5 text-xs gap-1.5 rounded-xl", default: "h-11 px-5 text-sm gap-2 rounded-2xl", lg: "h-13 px-7 text-base gap-2.5 rounded-2xl", }; export const AsciiHoverButton = React.forwardRef< HTMLButtonElement, AsciiHoverButtonProps >( ( { text = "Explore", children, variant = "segmented", size = "default", color = "default", active = false, asciiChars = DEFAULT_ASCII_CHARS, scrambleDuration = 0.75, leftIcon, rightIcon, className, disabled, onMouseEnter, onMouseLeave, ...props }, ref, ) => { const [isHovered, setIsHovered] = useState(false); const glitchRef = useRef(null); const handleMouseEnter = (e: React.MouseEvent) => { if (disabled) return; setIsHovered(true); glitchRef.current?.trigger(); onMouseEnter?.(e); }; const handleMouseLeave = (e: React.MouseEvent) => { setIsHovered(false); onMouseLeave?.(e); }; const buttonText = typeof children === "string" ? children : text; return ( ); }, ); AsciiHoverButton.displayName = "AsciiHoverButton"; export default AsciiHoverButton;