Skip to content
UI Kit
Accent color

Scramble

Letters scramble and resolve into the text when it scrolls in and again on hover. Seeded, so every visit looks the same, and nothing moves.

One of 5 components in Text effects

3 variants

Variants

Headline

A large line resolves from left to right, unresolved letters in the accent.

Code on scroll

A voucher code that decodes letter by letter with the scroll position, tabular digits, letters of the same case only.

Details

What Scramble brings along, read from the registry and the source.

Category
Text effects
Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/scramble.tsx
Brings along
utilskit
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The ScrambleProps interface from components/ui/scramble.tsx, with the comments from the code.

  • textrequired
    string

    The text it resolves to

  • asoptional
    Tag

    h1h2h3h4pspandiv

    Element to render. Default span.

  • triggeroptional
    ScrambleTrigger

    loadviewin-viewhoverscroll

    load: right away. view (or in-view): when it scrolls in (default). hover: only on hover and focus. scroll: the letters resolve from left to right with the scroll position and scramble again when you scroll back.

  • scrollRangeoptional
    [number, number]

    With trigger scroll: the part of the way through the window that resolves the text, 0 to 1. Default [0.05, 0.5].

  • replayOnHoveroptional
    boolean

    Scramble again on every hover and focus, also after view or load (not with scroll). Default true.

  • durationoptional
    number

    Milliseconds until the last letter has resolved. Default 1100.

  • delayoptional
    number

    Milliseconds before it starts. Default 0.

  • charactersoptional
    string

    Characters to scramble with. Default: letters of the same case, digits for digits.

  • seedoptional
    number

    Seed for the sequence of characters, the same seed gives the same scramble everywhere

  • toneoptional
    'accent' | 'inherit'

    Unresolved letters in the accent color (default) or in the text color

Accessibility

Read from the source of the component.

  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 264 lines
components/ui/scramble.tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';

/** in-view is the same as view */
export type ScrambleTrigger = 'load' | 'view' | 'in-view' | 'hover' | 'scroll';
type Tag = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'span' | 'div';

export interface ScrambleProps {
  /** The text it resolves to */
  text: string;
  /** Element to render. Default span. */
  as?: Tag;
  /**
   * load: right away. view (or in-view): when it scrolls in (default).
   * hover: only on hover and focus. scroll: the letters resolve from left to
   * right with the scroll position and scramble again when you scroll back.
   */
  trigger?: ScrambleTrigger;
  /** With trigger scroll: the part of the way through the window that resolves the text, 0 to 1. Default [0.05, 0.5]. */
  scrollRange?: [number, number];
  /** Scramble again on every hover and focus, also after view or load (not with scroll). Default true. */
  replayOnHover?: boolean;
  /** Milliseconds until the last letter has resolved. Default 1100. */
  duration?: number;
  /** Milliseconds before it starts. Default 0. */
  delay?: number;
  /** Characters to scramble with. Default: letters of the same case, digits for digits. */
  characters?: string;
  /** Seed for the sequence of characters, the same seed gives the same scramble everywhere */
  seed?: number;
  /** Unresolved letters in the accent color (default) or in the text color */
  tone?: 'accent' | 'inherit';
  className?: string;
}

const UPPER = 'ABCDEFGHJKLMNOPRSTUVWXYZ';
const LOWER = 'abcdefghijkmnoprstuvwxyz';
const DIGIT = '0123456789';

/*
 * The real text always lies in the box and gives it its size; while the
 * scramble runs it is invisible and the scrambled copy lies on top. Every
 * letter keeps the slot of its final letter, words stay whole, so nothing
 * moves and nothing wraps differently. Screen readers read a separate
 * copy of the text that never changes.
 */
const CSS = `
.kitfx-sc{position:relative;display:inline-block;max-width:100%}
div.kitfx-sc,p.kitfx-sc,h1.kitfx-sc,h2.kitfx-sc,h3.kitfx-sc,h4.kitfx-sc{display:block}
.kitfx-sc[data-run] .kitfx-sc-text{visibility:hidden}
.kitfx-sc-run{position:absolute;inset:0}
.kitfx-sc-w{white-space:nowrap}
.kitfx-sc-c{display:inline-grid;justify-items:center}
.kitfx-sc-c>*{grid-area:1/1}
.kitfx-sc-c>.kitfx-sc-f{visibility:hidden}
.kitfx-sc-c[data-open]>.kitfx-sc-g{color:var(--kitfx-sc-ink);-webkit-text-fill-color:var(--kitfx-sc-ink)}
.kitfx-sc[data-tone=accent]{--kitfx-sc-ink:color-mix(in oklch,var(--primary) 75%,currentColor)}
.kitfx-sc[data-tone=inherit]{--kitfx-sc-ink:color-mix(in oklch,currentColor 55%,transparent)}`;

/** Small, fast, seeded random numbers (mulberry32). Never used during render on the server. */
function seeded(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const hash = (s: string) => {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
  return h >>> 0;
};

const poolFor = (ch: string, custom?: string) => {
  if (custom) return custom;
  if (/\d/.test(ch)) return DIGIT;
  if (ch !== ch.toLowerCase()) return UPPER;
  if (ch !== ch.toUpperCase()) return LOWER;
  return '';
};

/**
 * Scroll link. Writes --kitfx-p (0 to 1) on the element: 0 when it has
 * travelled `start` of its way through the window, 1 at `end` (0 is its top
 * at the bottom edge, 1 its bottom at the top edge). The value is scaled to
 * the part of that way the page can still scroll, so a line at the end of a
 * short page still finishes. Never with reduced motion, then the CSS
 * fallback 1 keeps the final state.
 */
function useScrollLink(ref: { current: HTMLElement | null }, on: boolean, range: readonly [number, number], onChange?: (p: number) => void) {
  const [start, end] = range;
  const listener = useRef(onChange);
  listener.current = onChange;
  useEffect(() => {
    const el = ref.current;
    if (!el || !on || prefersReducedMotion()) return;
    let frame = 0;
    const span = Math.max(0.0001, end - start);
    const at = (top: number, h: number, vh: number) => Math.min(1, Math.max(0, ((vh - top) / (h + vh) - start) / span));
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight;
      const rest = Math.max(0, document.documentElement.scrollHeight - vh - window.scrollY);
      const most = at(r.top - rest, r.height, vh);
      const p = most < 0.02 ? 1 : Math.min(1, at(r.top, r.height, vh) / most);
      el.style.setProperty('--kitfx-p', p.toFixed(4));
      listener.current?.(p);
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      el.style.removeProperty('--kitfx-p');
    };
  }, [ref, on, start, end]);
}

/**
 * Letters scramble and resolve one after another into the text, when it
 * scrolls in and again on hover. The sequence is seeded, so it looks the
 * same on every visit. Screen readers only read the real text; without
 * JavaScript and with reduced motion the text simply stands there.
 *   <Scramble as="h2" className="text-h1" text="Atlas Studio" />
 */
export function Scramble({ text, as: Tag = 'span', trigger = 'view', scrollRange = [0.05, 0.5], replayOnHover = true, duration = 1100, delay = 0, characters, seed, tone = 'accent', className }: ScrambleProps) {
  const ref = useRef<HTMLElement>(null);
  const raf = useRef(0);
  const runs = useRef(0);
  const [frame, setFrame] = useState<string[] | null>(null);
  const [open, setOpen] = useState<boolean[]>([]);

  const play = useCallback(() => {
    if (prefersReducedMotion()) return;
    cancelAnimationFrame(raf.current);
    const chars = Array.from(text);
    const rand = seeded((seed ?? hash(text)) + runs.current++ * 7919);
    // When each letter resolves: left to right, with a little seeded unevenness
    const n = Math.max(1, chars.length - 1);
    const at = chars.map((_, i) => (i / n) * duration * 0.78 + rand() * duration * 0.22);
    const start = performance.now() + delay;
    let lastSwap = 0;
    let glyphs = chars.map((c) => c);
    const tick = (now: number) => {
      const t = now - start;
      if (t >= duration) {
        setFrame(null);
        return;
      }
      if (now - lastSwap > 72) {
        lastSwap = now;
        glyphs = chars.map((c, i) => {
          const pool = poolFor(c, characters);
          if (!pool || t >= at[i]!) return c;
          return pool[Math.floor(rand() * pool.length)]!;
        });
        setFrame(glyphs);
        setOpen(chars.map((c, i) => !!poolFor(c, characters) && t < at[i]!));
      }
      raf.current = requestAnimationFrame(tick);
    };
    setFrame(chars);
    setOpen(chars.map(() => false));
    raf.current = requestAnimationFrame(tick);
  }, [text, duration, delay, characters, seed]);

  // Scroll: letters left of the position are resolved, the others turn over now and then
  const scroll = trigger === 'scroll';
  const swap = useRef<{ at: number; glyphs: string[]; rand: () => number } | null>(null);
  useScrollLink(ref, scroll, scrollRange, (p) => {
    if (p >= 1) {
      setFrame(null);
      return;
    }
    const chars = Array.from(text);
    const now = performance.now();
    const s = (swap.current ??= { at: 0, glyphs: chars, rand: seeded(seed ?? hash(text)) });
    const fresh = now - s.at > 72;
    if (fresh) s.at = now;
    const done = Math.floor(p * (chars.length + 1));
    s.glyphs = chars.map((c, i) => {
      const pool = poolFor(c, characters);
      if (!pool || i < done) return c;
      return fresh || s.glyphs[i] === undefined ? pool[Math.floor(s.rand() * pool.length)]! : s.glyphs[i]!;
    });
    setFrame(s.glyphs);
    setOpen(chars.map((c, i) => !!poolFor(c, characters) && i >= done));
  });

  useEffect(() => {
    const el = ref.current;
    if (!el || trigger === 'hover' || scroll || prefersReducedMotion()) return;
    if (trigger === 'load') {
      play();
      return () => cancelAnimationFrame(raf.current);
    }
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        io.disconnect();
        play();
      },
      { rootMargin: '0px 0px -10% 0px' },
    );
    io.observe(el);
    return () => {
      io.disconnect();
      cancelAnimationFrame(raf.current);
    };
  }, [trigger, scroll, play]);

  const hover = !scroll && (trigger === 'hover' || replayOnHover) ? { onPointerEnter: play, onFocus: play } : {};

  // The scrambled copy: words as unbreakable groups, every letter in the slot of its final letter
  let k = 0;
  const words = frame
    ? text.split(/(\s+)/).map((part, wi) => {
        if (/^\s+$/.test(part)) {
          k += Array.from(part).length;
          return part;
        }
        return (
          <span key={wi} className="kitfx-sc-w">
            {Array.from(part).map((c) => {
              const i = k++;
              return (
                <span key={i} className="kitfx-sc-c" data-open={open[i] ? '' : undefined}>
                  <span className="kitfx-sc-f">{c}</span>
                  <span className="kitfx-sc-g">{frame[i] ?? c}</span>
                </span>
              );
            })}
          </span>
        );
      })
    : null;

  return (
    <Tag ref={ref as never} className={cn('kitfx-sc', className)} data-run={frame ? '' : undefined} data-tone={tone} {...hover}>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <span className="sr-only">{text}</span>
      <span aria-hidden className="kitfx-sc-text">
        {text}
      </span>
      {words && (
        <span aria-hidden className="kitfx-sc-run">
          {words}
        </span>
      )}
    </Tag>
  );
}

Frequent questions

How do I install Scramble?

With aiw add @ai-website/scramble. The command puts components/ui/scramble.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Scramble have?

3 variants, Headline, Links on hover and Code on scroll.

Can an AI assistant build with Scramble?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Scramble with aiw_components and install it with aiw_add @ai-website/scramble.

Often searched

Jump to

Copied to the clipboard