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
Details
What Scramble brings along, read from the registry and the source.
- Category
- Text effects
- Area
- Elements
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ScrambleProps interface from components/ui/scramble.tsx, with the comments from the code.
textrequiredstringThe text it resolves to
asoptionalTagh1h2h3h4pspandiv
Element to render. Default span.
triggeroptionalScrambleTriggerloadviewin-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].
replayOnHoveroptionalbooleanScramble again on every hover and focus, also after view or load (not with scroll). Default true.
durationoptionalnumberMilliseconds until the last letter has resolved. Default 1100.
delayoptionalnumberMilliseconds before it starts. Default 0.
charactersoptionalstringCharacters to scramble with. Default: letters of the same case, digits for digits.
seedoptionalnumberSeed 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
'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.