Shuffle
// preview
Text
Stagger (ms)
Scramble duration (ms)
Swap interval (ms)
Replay on hover
// source
TSJS
TailwindCSS
/*!
* Shuffle, a DesignPass.dev component by Ernest Liu (ernestliu.com)
* Docs & live playground: https://designpass.dev/components/shuffle
* MIT licensed, keep this notice in copies and adaptations.
*/
"use client";
import React, { useEffect, useMemo, useRef, type CSSProperties } from "react";
export interface ShuffleProps {
text: string;
/** Delay (ms) before the shuffle starts once in view. */
delay?: number;
/** Gap (ms) between one character locking in and the next. */
stagger?: number;
/** How long (ms) each character scrambles before locking. */
scrambleDuration?: number;
/** How often (ms) a scrambling character swaps glyphs. */
swapInterval?: number;
/** Glyph pool for the scramble; defaults to the case-matched alphabet. */
charset?: string;
/** Replay the shuffle when the pointer enters. */
triggerOnHover?: boolean;
/** IntersectionObserver threshold that triggers the shuffle. */
threshold?: number;
rootMargin?: string;
/** Play only the first time it enters the viewport. */
once?: boolean;
onComplete?: () => void;
className?: string;
style?: CSSProperties;
}
const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const LOWER = "abcdefghijklmnopqrstuvwxyz";
const DIGIT = "0123456789";
/** Case-matched pool keeps the scramble's texture close to the real text. */
function poolFor(char: string, charset?: string): string {
if (charset) return charset;
if (/[a-z]/.test(char)) return LOWER;
if (/[0-9]/.test(char)) return DIGIT;
return UPPER;
}
/**
* Characters riffle through random glyphs and lock into place one by one,
* left to right. A single rAF loop drives every cell (no per-char timers),
* and each cell's width is frozen up front so nothing jitters while the
* glyphs cycle. Zero dependencies, honors prefers-reduced-motion.
*/
export default function Shuffle({
text,
delay = 0,
stagger = 40,
scrambleDuration = 450,
swapInterval = 50,
charset,
triggerOnHover = true,
threshold = 0.15,
rootMargin = "0px",
once = true,
onComplete,
className = "",
style,
}: ShuffleProps) {
const containerRef = useRef<HTMLSpanElement>(null);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const words = useMemo(() => text.split(/\s+/).filter(Boolean), [text]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const cells = Array.from(container.querySelectorAll<HTMLElement>("[data-shuffle-cell]"));
if (cells.length === 0) return;
const finals = cells.map((cell) => cell.textContent ?? "");
// Freeze each cell at its final width so random glyphs (which may be
// wider or narrower) can't reflow the line while cycling.
for (const cell of cells) {
const { width } = cell.getBoundingClientRect();
cell.style.width = `${width}px`;
cell.style.textAlign = "center";
}
let frame = 0;
let startTime = 0;
let running = false;
const tick = (now: number) => {
const elapsed = now - startTime;
let allLocked = true;
for (let i = 0; i < cells.length; i++) {
const lockAt = delay + i * stagger + scrambleDuration;
if (elapsed >= lockAt) {
if (cells[i].textContent !== finals[i]) cells[i].textContent = finals[i];
continue;
}
allLocked = false;
if (elapsed < delay) continue;
// Each cell swaps on its own phase so columns don't flicker in sync.
const phase = Math.floor((elapsed + i * 17) / swapInterval);
const pool = poolFor(finals[i], charset);
cells[i].textContent = pool[(phase * 31 + i * 7) % pool.length];
}
if (allLocked) {
running = false;
onCompleteRef.current?.();
return;
}
frame = requestAnimationFrame(tick);
};
const play = () => {
if (running) return;
running = true;
startTime = performance.now();
frame = requestAnimationFrame(tick);
};
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
play();
if (once) observer.disconnect();
}
},
{ threshold, rootMargin },
);
observer.observe(container);
const onEnter = () => play();
if (triggerOnHover) container.addEventListener("mouseenter", onEnter);
return () => {
observer.disconnect();
container.removeEventListener("mouseenter", onEnter);
cancelAnimationFrame(frame);
cells.forEach((cell, i) => {
cell.textContent = finals[i];
cell.style.width = "";
cell.style.textAlign = "";
});
};
}, [
text,
delay,
stagger,
scrambleDuration,
swapInterval,
charset,
triggerOnHover,
threshold,
rootMargin,
once,
]);
return (
<span
ref={containerRef}
aria-label={text}
role="text"
className={`inline-block ${className}`}
style={style}
>
{words.map((word, wordIndex) => (
<React.Fragment key={`${word}-${wordIndex}`}>
<span aria-hidden="true" className="inline-block whitespace-nowrap">
{[...word].map((char, charIndex) => (
<span key={charIndex} data-shuffle-cell className="inline-block">
{char}
</span>
))}
</span>
{wordIndex < words.length - 1 ? " " : null}
</React.Fragment>
))}
</span>
);
}
// install
Install the Shuffle component from DesignPass into this project by running:
npx shadcn@latest add "https://designpass.dev/r/Shuffle-TS-TW.json"
If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.Need the license details? Read the component license.