DesignPass.dev

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.

// updates

Know when new components drop

A short email when something new lands in the library. No noise, unsubscribe anytime.