Scramble Link Button

A technical link button effect where text briefly scrambles before resolving for AI, cyber, and developer-tool CTAs.

Published On: July 9, 2026
Last Updated: August 17, 2026
Scramble Link Button

Overview

Scramble Link Button gives a button or link a small, useful moment of feedback: a link scrambles briefly before settling.

Use it where a CTA needs to feel responsive and intentional, not theatrical. A button should confirm action, state, or direction; it should not become a tiny stage play that delays the click.

The main risk is keeping the markup honest. Use genuine button and anchor elements, preserve focus, fire instantly, and make loading and disabled states obvious. The animation decorates the action; it is never the action.


Install Command

npx hyperiux add scramble-link-button

Usage Code

page.jsx
import ScrambleLinkButton from "@/components/effects/scramble-link-button";

export default function Page() {
    return (
        <>
        <div className="h-screen w-screen flex items-center justify-center relative">
                  <ScrambleLinkButton
                        href="#"
                        btnText ="Hover me"
                        showLine={true}
                        stepMs={50}
                        className="text-[2vw] max-sm:text-[4.5vw]"
                    />
</div>
        </>
    
    );
}

Component Code

index.jsx
// Built using Hyperiux Vault: https://vault.hyperiux.com
"use client";
import React, { useEffect, useRef, useState } from "react";
import { ArrowRight } from "lucide-react";
const DEFAULT_BTN_TEXT = "Hover me";
const DEFAULT_HREF = "#";
const DEFAULT_HOVER_COLOR = "#ff6b00";
const DEFAULT_SCRAMBLE_DURATION = 1000;
const DEFAULT_STEP_MS = 50;
const DEFAULT_REVEAL_STAGGER = 1.4;
const COMPACT_LAYOUT_BREAKPOINT = 1025;
const GLYPHS = "abcdefghijklmnopqrstuvwxyz0123456789";
function getScrambledText({ finalText, iteration, maxIterations, revealStagger, }) {
    let output = "";
    for (let index = 0; index < finalText.length; index += 1) {
        const char = finalText[index];
        if (char === " ") {
            output += char;
            continue;
        }
        const revealThreshold = (((index + 1) / finalText.length) * maxIterations) / revealStagger;
        if (iteration >= revealThreshold) {
            output += char;
            continue;
        }
        output += GLYPHS[Math.floor(Math.random() * GLYPHS.length)];
    }
    return output;
}
export function ScrambleLinkButton({ btnText = DEFAULT_BTN_TEXT, href = DEFAULT_HREF, className = "", textClassName = "", linkProps = {}, hoverColor = DEFAULT_HOVER_COLOR, showLine = false, lineClassName = "", showArrow = false, icon: Icon = ArrowRight, iconClassName = "", scrambleDuration = DEFAULT_SCRAMBLE_DURATION, stepMs = DEFAULT_STEP_MS, revealStagger = DEFAULT_REVEAL_STAGGER, onClick, ...props }) {
    const scrambleRef = useRef(null);
    const timeoutRef = useRef(null);
    const [isCompactLayout, setIsCompactLayout] = useState(false);
    const [isPressed, setIsPressed] = useState(false);
    const activeTextStyle = isCompactLayout && isPressed ? { color: hoverColor } : undefined;
    const activeLineStyle = isCompactLayout && isPressed ? { color: hoverColor } : undefined;
    const innerClassName = `relative inline-block ${showLine
        ? `w-fit after:absolute after:left-0 after:bottom-[-4%] after:h-[1.5px] after:w-full after:origin-right after:scale-x-0 after:bg-current after:transition-transform after:duration-[450ms] after:ease-[cubic-bezier(0.625,0.05,0,1)] after:content-[''] motion-reduce:after:transition-none motion-safe:group-hover:after:origin-left motion-safe:group-hover:after:scale-x-100 motion-safe:group-data-[pressed=true]:after:origin-left motion-safe:group-data-[pressed=true]:after:scale-x-100 ${lineClassName}`
        : ""}`;
    useEffect(() => {
        if (scrambleRef.current) {
            scrambleRef.current.textContent = btnText;
        }
        return () => {
            if (timeoutRef.current) {
                clearTimeout(timeoutRef.current);
            }
        };
    }, [btnText]);
    useEffect(() => {
        const mediaQuery = window.matchMedia(`(max-width: ${COMPACT_LAYOUT_BREAKPOINT - 1}px)`);
        const syncCompactLayout = (event) => {
            const matches = "matches" in event ? event.matches : event.currentTarget.matches;
            setIsCompactLayout(matches);
            if (!matches) {
                setIsPressed(false);
            }
        };
        syncCompactLayout(mediaQuery);
        mediaQuery.addEventListener("change", syncCompactLayout);
        return () => {
            mediaQuery.removeEventListener("change", syncCompactLayout);
        };
    }, []);
    const runScramble = () => {
        const element = scrambleRef.current;
        if (!element || !btnText.length) {
            return;
        }
        if (window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches) {
            element.textContent = btnText;
            return;
        }
        if (timeoutRef.current) {
            clearTimeout(timeoutRef.current);
        }
        let iteration = 0;
        const maxIterations = Math.max(1, Math.floor(scrambleDuration / stepMs));
        const updateScramble = () => {
            element.textContent = getScrambledText({
                finalText: btnText,
                iteration,
                maxIterations,
                revealStagger,
            });
            if (iteration >= maxIterations) {
                element.textContent = btnText;
                return;
            }
            iteration += 1;
            timeoutRef.current = setTimeout(updateScramble, stepMs);
        };
        updateScramble();
    };
    const onMouseEnter = () => {
        runScramble();
    };
    const handlePointerDown = (event) => {
        props.onPointerDown?.(event);
        if (!isCompactLayout || event.pointerType === "mouse") {
            return;
        }
        setIsPressed(true);
        runScramble();
    };
    const handlePointerUp = (event) => {
        props.onPointerUp?.(event);
    };
    const handlePointerCancel = (event) => {
        props.onPointerCancel?.(event);
    };
    return (<a href={href} {...linkProps} {...props} onClick={onClick} onMouseEnter={onMouseEnter} data-pressed={isPressed ? "true" : "false"} onPointerDown={handlePointerDown} onPointerUp={handlePointerUp} onPointerCancel={handlePointerCancel} className={`group inline-flex cursor-pointer items-center gap-2  text-[1.1vw] text-inherit no-underline transition-colors duration-350 ease-in-out motion-reduce:transition-none hover:text-(--scramble-hover-color) group-data-[pressed=true]:text-(--scramble-hover-color) max-lg:text-[3vw] max-md:text-[4.2vw] ${className}`} style={{ "--scramble-hover-color": hoverColor }}>
      <span className={innerClassName} style={activeLineStyle}>
        <span className={`pointer-events-none invisible inline-block select-none whitespace-pre [font-variant-ligatures:none] ${textClassName}`} style={activeTextStyle}>
          {btnText}
        </span>

        <span ref={scrambleRef} className={`absolute inset-0 inline-block whitespace-pre text-left [font-variant-ligatures:none] ${textClassName}`} style={activeTextStyle} aria-label={btnText}>
          {btnText}
        </span>
      </span>

      {showArrow && Icon && (<span className={`inline-flex items-center justify-center ${iconClassName}`}>
          <Icon className="transition-transform duration-300 ease-in-out motion-reduce:rotate-0 motion-reduce:transition-none motion-safe:group-hover:-rotate-45 motion-safe:group-data-[pressed=true]:-rotate-45"/>
        </span>)}
    </a>);
}
export default ScrambleLinkButton;

Example Production Use Case

Use this as button-interaction implementation guidance. Verify the shipped element semantics, anchor/button mode, disabled and loading states, focus-visible behavior, keyboard activation, and reduced-motion handling before relying on exact props, defaults, imports, or installation steps.


Best Used For

  • Technical brand links where a fast decode effect supports the visual tone.
  • Secondary CTAs that can settle instantly before the user loses the label.
  • Scramble Link Button adds feedback to an action without weakening semantics, focus, or activation speed.

Not For

Not for destructive actions, unclear states, or places where animation hides the label or delays activation.


Performance Budget

Keep feedback fast, avoid layout-triggering letter movement, and preserve instant activation.


Accessibility and Mobile

Use real buttons or anchors, visible focus-visible styles, and clear loading/disabled states. Touch targets should stay large enough on mobile.


Common Mistakes

  • Building Scramble Link Button with divs instead of real buttons or links.
  • Hiding the label during hover.
  • Forgetting loading and disabled states.

Changelog

v1.1.0

Jul 28, 2026
Documented existing prefers-reduced-motion support (was already implemented, not previously recorded): the character-scramble hover animation is skipped in favor of showing the final text immediately, and the underline/color/icon-rotate transitions are disabled via motion-reduce.

Props

PropTypeDefaultDescription
btnTextstringHover meButton label text.
hoverColorcolor#ff6b00Text color while hovered or pressed.
showLinebooleantrueShows the animated underline.
showArrowbooleanfalseShows the optional arrow icon.
scrambleDurationnumber1000Total scramble duration in milliseconds.
stepMsnumber50Delay between scramble frames.
revealStaggernumber1.4Controls how characters resolve across the word.

Frequently Asked Questions

When should I use Scramble Link Button?

Use it when a link can scramble briefly before settling as feedback — without delaying the action.

Request a Custom Button Animation

Need a custom effect? Tell us what to create.