Skip to content
Components

Scramble Text

Scramble Text brings controlled expression to your interface.

Open ↗

Make it yours.

Install the editable source and its dependencies into your React + Tailwind project.

Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/scramble-text.json

Use it

Scroll horizontally for long lines
"use client";
import {ScrambleText} from '@/components/jez-ui/ui/scramble-text';

export default function Example(){

return <><div className="w-full rounded-xl bg-foreground p-8 text-accent md:p-14"><ScrambleText className="font-display text-4xl font-medium tracking-tight md:text-6xl">Expect the unexpected.</ScrambleText><div className="mt-10 flex justify-between border-t border-current pt-4 text-xs"><span>Order from a little chaos.</span><span>Jez / Motion</span></div></div></>;
}

Props & types

Scroll horizontally for long lines
{
  children: string;
  className?: string;
}

Source

View 2 source files

registry/ui/scramble-text.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "./utils";
export function ScrambleText({
  children,
  className,
}: {
  children: string;
  className?: string;
}) {
  const [text, setText] = React.useState(children);
  const reduce = useReducedMotion();
  const timer = React.useRef<ReturnType<typeof setInterval> | null>(null);
  React.useEffect(() => {
    setText(children);
    return () => {
      if (timer.current) clearInterval(timer.current);
    };
  }, [children, reduce]);
  function run() {
    if (reduce) return;
    if (timer.current) clearInterval(timer.current);
    let step = 0;
    timer.current = setInterval(() => {
      step++;
      setText(
        Array.from(children)
          .map((c, i) =>
            i < step / 2 || /\s/.test(c)
              ? c
              : "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[Math.floor(Math.random() * 26)],
          )
          .join(""),
      );
      if (step >= children.length * 2 && timer.current)
        clearInterval(timer.current);
    }, 28);
  }
  let offset = 0;
  return (
    <span
      tabIndex={0}
      aria-label={children}
      onPointerEnter={run}
      onFocus={run}
      className={cn("inline-block", className)}
    >
      {children.split(/(\s+)/).map((word, w) => {
        const start = offset;
        offset += word.length;
        return /\s/.test(word) ? (
          <React.Fragment key={w}>{word}</React.Fragment>
        ) : (
          <span
            key={w}
            className="inline-flex whitespace-nowrap"
            aria-hidden="true"
          >
            {Array.from(word).map((char, i) => (
              <span key={i} className="relative inline-block">
                <span className="invisible">{char}</span>
                <span className="absolute inset-0 text-center">
                  {text[start + i] ?? char}
                </span>
              </span>
            ))}
          </span>
        );
      })}
    </span>
  );
}

registry/ui/utils.ts

Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
  return twMerge(clsx(values));
}

Dependencies

motion@13.2.0 · clsx@2.1.1 · tailwind-merge@3.5.0

Accessibility & behaviour

Respect reduced-motion preferences. Decorative effects must not carry essential information. WebGL scenes include a static fallback and only render while visible.

Read the accessibility and performance guide →