Terminal-style text that scrambles on hover — add techy flair in seconds
Hover over the text to see the scramble effect
Copy URL or Code
Paste to your AI coding assistant and customize:
Done. Your AI handles the rest.
Fully customizable. Perfect for navigation links, list items, and buttons. Works with any text on dark or light backgrounds.
/*
================================================================================
AI COMPONENT: TextScrambleHover
================================================================================
SETUP:
1. Create file: components/TextScrambleHover.tsx
2. Copy this entire code block into that file
3. Import: import TextScrambleHover from "@/components/TextScrambleHover"
4. No external dependencies required - pure React
================================================================================
QUICK CUSTOMIZATION (via props)
================================================================================
| Prop | Type | Default |
|----------------|-----------------------------|--------------|
| children | string | required |
| as | "span" | "a" | "button" | "span" |
| href | string | undefined |
| speed | number (ms per tick) | 30 |
| scrambleCycles | number (random chars shown) | 2 |
| showCursor | boolean | true |
| cursorStyle | "block" | "underline" | "block" |
| className | string | "" |
| onComplete | () => void | undefined |
================================================================================
EFFECT DESCRIPTION
================================================================================
A terminal-style text scramble effect where characters are hidden initially,
then revealed one by one with random character cycling. On hover:
1. All characters hide
2. Each character scrambles through random symbols
3. Characters reveal sequentially from left to right
Perfect for navigation links, buttons, and list items with a techy/hacker aesthetic.
================================================================================
SOURCE CODE
================================================================================
*/
"use client";
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
export type ElementType = "span" | "a" | "button";
export type CursorStyle = "block" | "underline";
export interface TextScrambleHoverProps {
children: string;
as?: ElementType;
href?: string;
speed?: number;
scrambleCycles?: number;
showCursor?: boolean;
cursorStyle?: CursorStyle;
className?: string;
onComplete?: () => void;
onClick?: () => void;
}
const CHARS = "abcdefghijklmnopqrstuvwxyz!@#$%^&*-_+=;:<>,";
interface CharState {
char: string;
visible: boolean;
iteration: number;
revealed: boolean;
}
export default function TextScrambleHover({
children,
as = "span",
href,
speed = 30,
scrambleCycles = 2,
showCursor = true,
cursorStyle = "block",
className = "",
onComplete,
onClick,
}: TextScrambleHoverProps) {
const [charStates, setCharStates] = useState<CharState[]>([]);
const [cursorIndex, setCursorIndex] = useState<number>(-1);
const [isAnimating, setIsAnimating] = useState(false);
const animationRef = useRef<NodeJS.Timeout | null>(null);
const currentIndexRef = useRef(0);
const originalChars = useMemo(() => children.split(""), [children]);
// Initialize with all characters visible (idle state)
useEffect(() => {
setCharStates(originalChars.map((char) => ({ char, visible: true, iteration: 0, revealed: true })));
}, [originalChars]);
const getRandomChar = useCallback(() => CHARS[Math.floor(Math.random() * CHARS.length)], []);
const startAnimation = useCallback(() => {
if (isAnimating) return;
setIsAnimating(true);
currentIndexRef.current = 0;
// Hide all characters at start
setCharStates(originalChars.map((char) => ({ char: char === " " ? " " : "", visible: false, iteration: 0, revealed: false })));
setCursorIndex(0);
const animate = () => {
const currentCharIndex = currentIndexRef.current;
if (currentCharIndex >= originalChars.length) {
setCharStates(originalChars.map((char) => ({ char, visible: true, iteration: 0, revealed: true })));
setCursorIndex(-1);
setIsAnimating(false);
onComplete?.();
return;
}
setCharStates((prev) => {
const newStates = prev.map((state, index) => {
const originalChar = originalChars[index];
if (originalChar === " ") return { ...state, char: " ", visible: true, revealed: true };
if (state.revealed) return { ...state, char: originalChar, visible: true };
if (index === currentCharIndex) {
const newIteration = state.iteration + 1;
if (newIteration >= scrambleCycles) {
return { char: originalChar, visible: true, iteration: newIteration, revealed: true };
}
return { char: getRandomChar(), visible: true, iteration: newIteration, revealed: false };
}
return state;
});
if (newStates[currentCharIndex]?.revealed) {
currentIndexRef.current++;
setCursorIndex(currentIndexRef.current < originalChars.length ? currentIndexRef.current : -1);
}
return newStates;
});
animationRef.current = setTimeout(animate, speed);
};
animate();
}, [originalChars, scrambleCycles, speed, getRandomChar, isAnimating, onComplete]);
const stopAnimation = useCallback(() => {
if (animationRef.current) { clearTimeout(animationRef.current); animationRef.current = null; }
setCharStates(originalChars.map((char) => ({ char, visible: true, iteration: 0, revealed: true })));
setCursorIndex(-1);
setIsAnimating(false);
}, [originalChars]);
useEffect(() => { return () => { if (animationRef.current) clearTimeout(animationRef.current); }; }, []);
const handleMouseEnter = useCallback(() => { startAnimation(); }, [startAnimation]);
const handleMouseLeave = useCallback(() => {}, []);
const commonProps = {
className: `inline-block font-mono uppercase tracking-wide cursor-pointer ${className}`,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
onClick,
style: { fontKerning: "none" as const },
};
const renderContent = () => (
<span className="relative inline-flex">
{charStates.map((state, index) => (
<span key={index} className="relative inline-block" style={{ width: originalChars[index] === " " ? "0.5ch" : "1ch", opacity: state.visible ? 1 : 0 }}>
{state.char}
{showCursor && index === cursorIndex && (
<span className={`absolute left-0 ${cursorStyle === "block" ? "top-0 w-full h-full bg-current opacity-80" : "bottom-0 w-full h-[2px] bg-current"}`} />
)}
</span>
))}
</span>
);
if (as === "a") return <a href={href} {...commonProps}>{renderContent()}</a>;
if (as === "button") return <button {...commonProps}>{renderContent()}</button>;
return <span {...commonProps}>{renderContent()}</span>;
}
export { TextScrambleHover };import TextScrambleHover from "@/components/TextScrambleHover";
// Basic usage - span (default)
export function BasicExample() {
return (
<div className="bg-gray-900 p-8">
<TextScrambleHover className="text-white text-xl">
Hover over me
</TextScrambleHover>
</div>
);
}
// As navigation link
export function NavigationExample() {
return (
<nav className="bg-black p-4 space-y-2">
<TextScrambleHover as="a" href="/about" className="text-white block">
About Us
</TextScrambleHover>
<TextScrambleHover as="a" href="/work" className="text-white block">
Our Work
</TextScrambleHover>
<TextScrambleHover as="a" href="/contact" className="text-white block">
Contact
</TextScrambleHover>
</nav>
);
}
// As button with custom speed
export function ButtonExample() {
return (
<TextScrambleHover
as="button"
speed={30}
iterations={2}
className="px-6 py-3 bg-white text-black rounded-lg"
onClick={() => console.log("clicked!")}
>
Click Me
</TextScrambleHover>
);
}
// Terminal-style list
export function ListExample() {
const items = ["Initialize", "Configure", "Deploy", "Monitor"];
return (
<div className="bg-gray-900 p-6 font-mono">
{items.map((item, i) => (
<div key={i} className="flex items-center gap-4 py-2 text-gray-300">
<span className="text-gray-500">{String(i + 1).padStart(2, "0")}</span>
<TextScrambleHover className="text-green-400">
{item}
</TextScrambleHover>
</div>
))}
</div>
);
}
// With underline cursor style
export function UnderlineExample() {
return (
<TextScrambleHover
cursorStyle="underline"
className="text-white text-2xl"
>
Underline Cursor
</TextScrambleHover>
);
}
// No cursor (clean scramble)
export function NoCursorExample() {
return (
<TextScrambleHover
showCursor={false}
className="text-white text-lg"
>
Clean Scramble Effect
</TextScrambleHover>
);
}