On hover or focus, the letters swap places a few times and slide home. Made for links and buttons.
Customize
Usage
import { ShuffleText } from "@/components/motionkit/shuffle-text";
<a href="/about">
<ShuffleText text="About us" />
</a>Motion
The exact curves this component runs, plotted with Motion's own maths.
Swaps
Hover or focus
Slide to new slot
Each swap
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/shuffle-text.tsx.
"use client";
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type ShuffleTextProps = {
text: string;
/** How many times the letters swap places before settling. */
rounds?: number;
/** Milliseconds between swaps. */
speed?: number;
/**
* "parent" plays when the pointer enters (or focus lands on) the enclosing element, such as a
* link or button; "self" listens to the text only.
*/
target?: "parent" | "self";
className?: string;
};
function shuffled(count: number) {
const order = Array.from({ length: count }, (_, i) => i);
for (let i = count - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
return order;
}
export function ShuffleText({ text, rounds = 3, speed = 90, target = "parent", className = "" }: ShuffleTextProps) {
const ref = useRef<HTMLSpanElement>(null);
const reduceMotion = useReducedMotion();
const letters = Array.from(text);
// order[i] is the slot letter i occupies; the identity order spells the word
const [order, setOrder] = useState<number[] | null>(null);
useEffect(() => {
const self = ref.current;
const area = target === "parent" ? self?.parentElement : self;
if (!area || reduceMotion) return;
let timers: number[] = [];
function play() {
if (timers.length) return;
for (let round = 0; round <= rounds; round++) {
timers.push(
window.setTimeout(() => {
// the last round puts every letter home
setOrder(round === rounds ? null : shuffled(letters.length));
if (round === rounds) timers = [];
}, round * speed),
);
}
}
area.addEventListener("pointerenter", play);
area.addEventListener("focusin", play);
return () => {
timers.forEach(window.clearTimeout);
area.removeEventListener("pointerenter", play);
area.removeEventListener("focusin", play);
};
}, [target, rounds, speed, reduceMotion, letters.length]);
return (
<span ref={ref} className={`relative inline-flex ${className}`}>
<span className="sr-only">{text}</span>
{letters.map((char, i) => (
// flex order moves a letter to another slot; layout animation slides it there
<motion.span
key={i}
aria-hidden
layout="position"
transition={{ type: "spring", stiffness: 500, damping: 32 }}
style={{ order: order ? order[i] : i }}
className="inline-block whitespace-pre"
>
{char}
</motion.span>
))}
</span>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | — | The word or short label. |
| rounds | number | 3 | Swaps before settling. |
| speed | number | 90 | Milliseconds between swaps. |
| target | "parent" | "self" | "parent" | Listen to the enclosing link or button, or to the text only. |
Text Scramble
Text that decodes itself character by character, terminal style.
Come closer
Variable Proximity
Letters near the cursor swell to a heavier weight and ease back as it moves on, using a variable font.
Pull me
Magnetic Letters
Each letter leans toward the cursor and springs home, like a magnetic button for every character.