Scramble on Hover
Scramble on Hover makes the letters of a word flicker through random characters when the mouse moves over it, then settle on the real word. It adds a little life to links and buttons without changing what they say.
import { ArrowUpRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrambleOnHover } from "@/components/ui/scramble-on-hover";
import { focusRing } from "@/lib/styles";
const link = `rounded-sm text-3xl font-semibold uppercase text-text-secondary transition-colors hover:text-text ${focusRing}`;
export default function ScrambleOnHoverDemo() {
return (
<div className="flex w-full flex-col items-center gap-8">
<div className="flex flex-wrap items-center justify-center gap-x-8 gap-y-4">
{["Home", "Product", "Pricing"].map((label) => (
<ScrambleOnHover key={label} render={<a href="#" className={link} />}>
{label}
</ScrambleOnHover>
))}
<ScrambleOnHover
render={<a href="#" className={`inline-flex items-center gap-1 ${link}`} />}
after={<ArrowUpRight aria-hidden className="size-6" />}
>
GitHub
</ScrambleOnHover>
</div>
<ScrambleOnHover render={<Button variant="secondary" />}>Get started</ScrambleOnHover>
<ScrambleOnHover render={<p className="max-w-xs text-center text-lg text-text/80" />}>
Hover over this sentence and every word scrambles in its own place.
</ScrambleOnHover>
</div>
);
}The code
The component is a single file, read from the repository when the site is built. Press Show code to see all of it.
"use client";
import type { ReactNode } from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { ScrambledText, useScramble } from "@/lib/scramble";
export interface ScrambleOnHoverProps extends Omit<useRender.ComponentProps<"span">, "children"> {
// The text. It must be a plain string.
children: string;
// The most time the effect takes, in milliseconds. A short word finishes sooner.
duration?: number;
// The characters the random letters are picked from.
characters?: string;
// Something inside the same element, before or after the text, such as an icon. It does not
// scramble.
before?: ReactNode;
after?: ReactNode;
}
// When the mouse moves over it, the letters of the text flicker through random characters and
// settle on the real ones from left to right. It is a span by default; pass `render` to put the
// effect on your own element, such as a link or a Button, and the whole element reacts to the
// hover, not only the letters. Keyboard focus and touch do nothing, and neither does a visitor
// who asks for reduced motion.
export function ScrambleOnHover({
children,
duration = 700,
characters,
before,
after,
render,
...props
}: ScrambleOnHoverProps) {
const { display, play, stop } = useScramble(children, { duration, characters });
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(
{
onPointerEnter: (event) => {
if (event.pointerType === "mouse") play();
},
onPointerLeave: stop,
children: (
<>
{before}
<ScrambledText text={children} display={display} />
{after}
</>
),
},
props,
),
});
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
children | string | — | The text. It must be a plain string. Required. |
render | element | — | Puts the effect on your own element, such as a link or a Button, instead of a span. The hover is read on that element, so the whole link reacts and not only its letters. |
duration | number | 700 | The most time the effect takes, in milliseconds. A short word finishes sooner, at about 80ms per letter. |
characters | string | A–Z, 0–9 and #%&*+? | The characters the random letters are picked from. |
before / after | ReactNode | — | Something inside the same element, before or after the text, such as an icon or a line. It does not scramble. |
Every other prop goes to the element, a span or the one you pass as render.
The thinking
The effect is a flourish on something people already hover: a link, a button, a word in a menu. It should never be the reason they notice it is there.
- One element does the work: It listens for the hover on itself. With render you choose which element that is, so a link can react across its whole row.
- Nothing moves: The real word stays underneath, invisible, and holds the width. The random characters sit on top of it and are clipped to it, so the layout never shifts.
- Words, not sentences: Every word keeps its own width, so a sentence that wraps still wraps in the same places while it flickers.
- Left alone: Spaces and punctuation never change, and a lowercase letter is only ever replaced by a lowercase one.
Interface design
- It is only the letters: Size, weight, capitals and color come from the element, so the effect matches whatever text it is on.
- The noise: Capital letters, digits and a few symbols, which you can change with characters.
- The timing: About a tenth of a second of pure noise, then the real letters settle from left to right, about 80ms apart. A long text is sped up so it never takes longer than duration.
Interaction
- Hover: The effect starts when the mouse (or a pen) enters the element, and starts again if it leaves and comes back.
- Leaving: If the mouse leaves in the middle of the effect, the real text comes back at once.
- Keyboard and touch: Nothing happens on keyboard focus or on a tap, so the text is always steady for them.
- Reduced motion: For people who ask their system for less motion, nothing flickers.
Empathy
- Screen readers: They always get the real text, from a visually hidden copy. The flickering copy is hidden from them, so they never hear random letters.
- Never the only signal: The effect carries no meaning. Keep your own hover style on the element, as the demo does with its color.
- Use it sparingly: Moving letters draw the eye. A few links in a menu is enough; a whole page of them is tiring.
- Short words read best: The effect is quick enough to read through, but a long sentence flickering under the pointer is harder to follow.