Split Text
TextSplits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view.
Random characters resolve into the real text from left to right when it scrolls into view, on load, or on hover. The server HTML is the final string; a small requestAnimationFrame loop does the rest and never runs under reduced motion.
Decrypting the catalogue
hover to decode
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
921 B raw
530 B gzip.
Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.6 kB raw.
CSS 4.2 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
every browser.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import TextScramble from '@moonarc/core/TextScramble';
---
<TextScramble as="h2" text="Decrypting…" />
<TextScramble text="hover me" trigger="hover" charset="01" />Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/text-scramble.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.
---
/**
* TextScramble — random characters resolve into the text, left to right.
*
* The server HTML is the final string. On trigger, a small rAF loop swaps
* each character for a random one until that character's lock time, then
* writes the real one. Bound through the runtime; never runs under reduced
* motion, so the text is simply there.
*/
import type { HTMLAttributes, HTMLTag } from 'astro/types';
interface Props extends HTMLAttributes<'span'> {
/** Final text. */
text: string;
/** Element to render. */
as?: HTMLTag;
/** Milliseconds between one character locking and the next. */
speed?: number;
/** Characters to scramble with. */
charset?: string;
/** When to run. */
trigger?: 'view' | 'load' | 'hover';
/** Delay before the first lock, ms. */
delay?: number;
}
const { text, as: Tag = 'span', speed = 30, charset = '!<>-_\\/[]{}—=+*^?#', trigger = 'view', delay = 0, class: className, ...rest } = Astro.props;
---
<Tag class:list={['ma-scramble', className]} data-ma-scramble data-speed={speed} data-charset={charset} data-trigger={trigger} data-delay={delay} {...rest}>
<span class="ma-scramble__text" aria-hidden="true">{text}</span>
<span class="ma-sr">{text}</span>
</Tag>
<style is:global>
@layer components {
:where(.ma-scramble__text) {
font-variant-ligatures: none;
}
:where(.ma-scramble[data-running] .ma-scramble__text) {
/* keep the box stable while glyph widths churn */
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-scramble__text) {
/* the script never binds; the final text is already here */
opacity: 1;
}
}
}
</style>
<script>
import { onMount } from '../lib/runtime';
onMount<HTMLElement>('[data-ma-scramble]', (el, { signal, reducedMotion }) => {
if (reducedMotion) return;
const node = el.querySelector<HTMLElement>('.ma-scramble__text');
if (!node) return;
const final = node.textContent ?? '';
const chars = [...final];
const speed = Number(el.dataset.speed ?? 30);
const set = el.dataset.charset ?? '!<>-_\\/[]{}';
const delay = Number(el.dataset.delay ?? 0);
let raf = 0;
let start = 0;
const frame = (now: number) => {
let out = '';
let done = true;
chars.forEach((c, i) => {
if (c === ' ' || now >= start + i * speed) out += c;
else {
done = false;
out += set[Math.floor(Math.random() * set.length)];
}
});
node.textContent = out;
if (done) delete el.dataset.running;
else raf = requestAnimationFrame(frame);
};
const run = () => {
cancelAnimationFrame(raf);
start = performance.now() + delay;
el.dataset.running = '';
raf = requestAnimationFrame(frame);
};
// Torn down mid-scramble (an element persisted across a swap), it runs to the end at once: every character
// locked, the real text back. The next bind reads its final string from the node, and read the noise instead.
signal.addEventListener('abort', () => {
cancelAnimationFrame(raf);
frame(Infinity);
});
const trigger = el.dataset.trigger;
if (trigger === 'hover') el.addEventListener('pointerenter', run, { signal });
else if (trigger === 'load') run();
else {
const io = new IntersectionObserver(([e]) => {
if (e!.isIntersecting) {
run();
io.disconnect();
}
}, { threshold: 0.4 });
io.observe(el);
signal.addEventListener('abort', () => io.disconnect());
}
});
</script>| Prop | Type | Default | Description |
|---|---|---|---|
text | string | none | Final text. |
as | HTMLTag | 'span' | Element to render. |
speed | number | 30 | Milliseconds between one character locking and the next; 20–40 reads as decoding. |
charset | string | '!<>-_\\/[]{}—=+*^?#' | Characters to scramble with. |
trigger | 'view' | 'load' | 'hover' | 'view' | When to run. |
delay | number | 0 | Delay before the first lock, ms. |
The script does not bind; the final text is shown.
ClientRouterBound through the shared runtime; the loop is cancelled before a swap and the real text put back, so an element persisted mid-scramble never keeps the noise.
Replaces: DecryptedText (React Bits) · HyperText (Magic UI) · TextScramble (Aceternity). See the migration table.
Splits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view.
Types a string one character at a time in any font, with a caret that stays solid while typing and blinks once idle.
A wave of boldness rolls through the letters of a line, each character animating its font weight on its own delay.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown