Buttons
TextDisperseLink
Link whose letters scatter to preset offsets on hover and regroup in sequence.
npx bezel-add add text-disperse-linkAbout
A text scatter hover effect for React links: on hover each letter flies to its own offset and rotation, and on leave the letters regroup one after another. Built with GSAP, the scatter pattern is fixed rather than random, so it looks the same every time, and offsets scale with the font size. Use it for footer, social or navigation links that should feel playful.
Key props: label (link text), href (link target), target / rel (standard anchor attributes for external links)
packages/ui/src/buttons/TextDisperseLink.tsx · 74 lines
"use client";
import { useRef } from "react";
import gsap from "gsap";
const SCATTER = [
{ x: -0.8, y: -0.6, rz: -29 }, { x: -0.2, y: -0.4, rz: -6 },
{ x: -0.5, y: 0.3, rz: -14 }, { x: 0.3, y: -0.5, rz: 10 },
{ x: 0.6, y: 0.4, rz: 18 }, { x: -0.4, y: 0.7, rz: -22 },
{ x: 0.1, y: -0.8, rz: 5 }, { x: 0.8, y: 0.6, rz: 20 },
];
type Props = {
label: string;
href: string;
target?: string;
rel?: string;
className?: string;
};
export function TextDisperseLink({ label, href, target, rel, className = "" }: Props) {
const linkRef = useRef<HTMLAnchorElement>(null);
const scatter = () => {
const chars = linkRef.current?.querySelectorAll<HTMLElement>(".yui-dchar");
if (!chars) return;
chars.forEach((ch, i) => {
const s = SCATTER[i % SCATTER.length];
const em = parseFloat(getComputedStyle(ch).fontSize) || 16;
gsap.to(ch, {
x: s.x * em,
y: s.y * em,
rotateZ: s.rz,
duration: 0.4,
ease: "power3.out",
});
});
};
const gather = () => {
const chars = linkRef.current?.querySelectorAll<HTMLElement>(".yui-dchar");
if (!chars) return;
gsap.to(Array.from(chars), {
x: 0, y: 0, rotateZ: 0,
duration: 0.5, ease: "power3.out", stagger: 0.015,
});
};
return (
<>
<style>{`
.yui-disperse-link {
display: inline-flex; gap: 0; cursor: pointer;
text-decoration: none; font-weight: 500; text-transform: uppercase;
letter-spacing: 0.03em;
}
.yui-disperse-link:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.yui-dchar { display: inline-block; will-change: transform; }
`}</style>
<a
ref={linkRef}
href={href}
target={target}
rel={rel}
className={["yui-disperse-link", className].filter(Boolean).join(" ")}
onMouseEnter={scatter}
onMouseLeave={gather}
>
{label.split("").map((ch, i) => (
<span key={i} className="yui-dchar">{ch === " " ? "\u00A0" : ch}</span>
))}
</a>
</>
);
}