Scramble on Load

Scramble on Load makes the letters of a heading or a line of text flicker through random characters and settle on the real ones, as soon as the text is on the screen: when the page loads, or when you scroll to it.

Own every pixel

A line of text that starts a moment after the heading and settles from left to right.

"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { ScrambleOnLoad } from "@/components/ui/scramble-on-load";

export default function ScrambleOnLoadDemo() {
  // The effect plays once, so changing the key remounts the text to play it again.
  const [run, setRun] = useState(0);

  return (
    <div className="flex w-full flex-col items-center gap-8">
      <div key={run} className="flex flex-col items-center gap-3 text-center">
        <ScrambleOnLoad render={<h2 className="text-4xl font-bold tracking-tight" />}>
          Own every pixel
        </ScrambleOnLoad>
        <ScrambleOnLoad delay={500} render={<p className="max-w-sm text-lg text-text/80" />}>
          A line of text that starts a moment after the heading and settles from left to right.
        </ScrambleOnLoad>
      </div>
      <Button variant="secondary" onClick={() => setRun((value) => value + 1)}>
        Replay
      </Button>
    </div>
  );
}

The code

The component is a single file, read from the repository when the site is built. Press Show code to see all of it.

"use client";

import { useEffect, useRef, type ReactNode } from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { ScrambledText, useScramble } from "@/lib/scramble";

export interface ScrambleOnLoadProps extends Omit<useRender.ComponentProps<"span">, "children"> {
  // The text. It must be a plain string.
  children: string;
  // The most time the effect takes, in milliseconds. A short word finishes sooner.
  duration?: number;
  // How long to wait, in milliseconds, after the text comes into view before it starts.
  delay?: number;
  // Plays only the first time the text comes into view. Pass false to play again every time it
  // scrolls out of view and back.
  once?: boolean;
  // The characters the random letters are picked from.
  characters?: string;
  // Something inside the same element, before or after the text, such as an icon. It does not
  // scramble.
  before?: ReactNode;
  after?: ReactNode;
}

// The letters of the text flicker through random characters and settle on the real ones from
// left to right, as soon as it is on the screen: when the page loads, if it is already in view,
// or when it scrolls into view. It is a span by default; pass `render` to make it a heading or a
// paragraph. Nobody sees the effect if they ask for reduced motion.
export function ScrambleOnLoad({
  children,
  duration = 1200,
  delay = 0,
  once = true,
  characters,
  before,
  after,
  render,
  ...props
}: ScrambleOnLoadProps) {
  const { display, play, stop } = useScramble(children, { duration, characters });
  const element = useRef<HTMLElement>(null);

  useEffect(() => {
    const target = element.current;
    if (!target) return;
    let timer: number | undefined;
    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            timer = window.setTimeout(play, delay);
            if (once) observer.disconnect();
          } else if (!once) {
            window.clearTimeout(timer);
            stop();
          }
        }
      },
      { threshold: 0.2 },
    );
    observer.observe(target);
    return () => {
      observer.disconnect();
      window.clearTimeout(timer);
    };
  }, [play, stop, delay, once]);

  return useRender({
    defaultTagName: "span",
    render,
    ref: element,
    props: mergeProps<"span">(
      {
        children: (
          <>
            {before}
            <ScrambledText text={children} display={display} />
            {after}
          </>
        ),
      },
      props,
    ),
  });
}

Props

PropTypeDefaultWhat it does
childrenstring—The text. It must be a plain string. Required.
renderelement—Makes it your own element, such as a heading or a paragraph, instead of a span.
durationnumber1200The most time the effect takes, in milliseconds. A short word finishes sooner, at about 80ms per letter.
delaynumber0How long to wait after the text comes into view before it starts, in milliseconds.
oncebooleantruePlays only the first time the text comes into view. Pass false to play it again every time it scrolls out of view and back.
charactersstringA–Z, 0–9 and #%&*+?The characters the random letters are picked from.
before / afterReactNode—Something inside the same element, before or after the text, such as an icon or a line. It does not scramble.

Every other prop goes to the element, a span or the one you pass as render.

The thinking

A heading that assembles itself is a way to say: this is the start. It works best once per page, on the one line you want read first.

  • Starts when it is seen: It waits until at least a fifth of the text is on the screen, so a heading far down the page plays when you reach it, not while you cannot see it.
  • Once by default: Scrolling back up does not replay it. Set once to false for text that should play every time it returns.
  • Same parts as Scramble on Hover: The real text holds the width and the layout never moves, and every word keeps its own width. Spaces and punctuation are left alone.
  • Headings and paragraphs: Pass render to make it an h1 or a p. A heading keeps its meaning for screen readers.

Interface design

  • Only the letters: Size, weight and color come from the element you render, so it matches the page.
  • Slower than hover: It takes up to 1.2 seconds by default, because it is a moment to watch and not a reaction to a pointer.
  • Staggering: Give a second line a delay, as the demo does, and it starts after the first instead of at the same time.

Interaction

  • Play: It plays as soon as the element is on the screen, which for text at the top of a page is right after the page loads.
  • Scroll: Text below the fold stays steady until you scroll to it, then plays.
  • Replay: With once set to false it plays again each time it comes back into view, and shows the real text while it is out of view.
  • Reduced motion: For people who ask their system for less motion, the text is simply shown.

Empathy

  • Screen readers: They get the real text from a visually hidden copy, and never hear the random letters.
  • Before the scripts load: The real text is in the page from the start, so it can be read, searched and found before any effect runs.
  • Easy to wait for: Keep it short and fast. People should never have to wait for a heading to finish before they can read it.
  • One at a time: A few moving headings on a page are plenty. Too many make the page feel unsteady.