Fazekit

Code

"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

type Tag = "h1" | "h2" | "h3" | "p" | "span" | "div" | "a" | "button";

export interface ScrambleTextProps {
  text: string;
  as?: Tag;
  /** What starts the effect. Several triggers can be combined. */
  trigger?: Array<"mount" | "hover" | "focus" | "inView">;
  /** Total duration in ms until the last character settles. */
  duration?: number;
  /** How often (ms) unresolved characters pick a new glyph. */
  tick?: number;
  /** Glyph pool used while scrambling. */
  characters?: string;
  /** Resolve left-to-right (true) or all characters at random moments (false). */
  sequential?: boolean;
  /** Class for characters that have not resolved yet. */
  scrambleClassName?: string;
  className?: string;
  onDone?: () => void;
  /** Extra props for the rendered element (href, onClick…). */
  elementProps?: React.HTMLAttributes<HTMLElement> & { href?: string };
}

const DEFAULT_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=<>/\\[]{}";

type Cell = { char: string; done: boolean };

/**
 * Text that flickers through random glyphs and settles on its final value.
 * Renders the final text on the server and on first paint; glyphs are only generated inside effects,
 * so it is hydration safe.
 */
export function ScrambleText({
  text,
  as = "span",
  trigger = ["mount", "hover"],
  duration = 900,
  tick = 40,
  characters = DEFAULT_GLYPHS,
  sequential = true,
  scrambleClassName = "text-primary",
  className,
  onDone,
  elementProps,
}: ScrambleTextProps) {
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLElement>(null);
  const [cells, setCells] = React.useState<Cell[] | null>(null);
  const raf = React.useRef(0);
  const inView = useInView(ref, { once: true, amount: 0.6 });
  const onDoneRef = React.useRef(onDone);
  React.useEffect(() => {
    onDoneRef.current = onDone;
  }, [onDone]);

  const run = React.useCallback(() => {
    if (reduce) return;
    cancelAnimationFrame(raf.current);
    const chars = Array.from(text);
    // Each character gets its settle time up-front.
    const settle = chars.map((_, i) =>
      sequential ? (duration * 0.35) + (i / Math.max(1, chars.length - 1)) * duration * 0.65 : duration * (0.3 + Math.random() * 0.7),
    );
    const start = performance.now();
    let last = 0;
    const loop = (now: number) => {
      const t = now - start;
      if (now - last >= tick || t >= duration) {
        last = now;
        const next = chars.map((c, i) => {
          if (c === " " || t >= settle[i]) return { char: c, done: true };
          return { char: characters[Math.floor(Math.random() * characters.length)], done: false };
        });
        setCells(next);
      }
      if (t < duration) raf.current = requestAnimationFrame(loop);
      else {
        setCells(null);
        onDoneRef.current?.();
      }
    };
    raf.current = requestAnimationFrame(loop);
  }, [text, duration, tick, characters, sequential, reduce]);

  const onMount = trigger.includes("mount");
  const onInView = trigger.includes("inView");
  React.useEffect(() => {
    if (onMount) run();
    return () => cancelAnimationFrame(raf.current);
  }, [onMount, run]);
  React.useEffect(() => {
    if (onInView && inView) run();
  }, [onInView, inView, run]);

  const Comp = as as React.ElementType;
  return (
    <Comp
      ref={ref}
      {...elementProps}
      onPointerEnter={(e: React.PointerEvent<HTMLElement>) => {
        elementProps?.onPointerEnter?.(e);
        if (trigger.includes("hover")) run();
      }}
      onFocus={(e: React.FocusEvent<HTMLElement>) => {
        elementProps?.onFocus?.(e);
        if (trigger.includes("focus")) run();
      }}
      className={cn("whitespace-pre-wrap", className)}
    >
      <span className="sr-only">{text}</span>
      {/* The final text always reserves the layout, so scrambling never shifts surrounding content. */}
      <span aria-hidden className="relative inline-block">
        <span className={cells ? "invisible" : undefined}>{text}</span>
        {cells && (
          <span className="absolute inset-0">
            {cells.map((c, i) => (
              <span key={i} className={c.done ? undefined : scrambleClassName}>
                {c.char}
              </span>
            ))}
          </span>
        )}
      </span>
    </Comp>
  );
}

More in Text Effects

View all →