Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Scroll text scramble and slide" for my site using the Enrichr Library element at https://enrichr.dev/elements/scroll-text-scramble-flip.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/scroll-text-scramble-flip.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- accent: #e6ff55
- label: Enrichr
- travel: 1
- scramble: 1
- groups: 6
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/scroll-text-scramble-flip.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-stm">
<div class="ef-stm__wrap">
<svg class="ef-stm__piece ef-stm__piece--a" viewBox="1513.7 166.86 322.3 503.06" aria-hidden="true" focusable="false"><path fill="currentColor" d="M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"/></svg>
<svg class="ef-stm__piece ef-stm__piece--b" viewBox="1513.7 1453.9 322.3 322.3" aria-hidden="true" focusable="false"><path fill="currentColor" d="M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"/></svg>
<div class="ef-stm__label" aria-hidden="true"></div>
<div class="ef-stm__scroller" tabindex="0" role="region" aria-label="Scroll to slide and scramble the text lines">
<div class="ef-stm__content"></div>
</div>
<div class="ef-stm__hint" aria-hidden="true">Scroll</div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
/* Adapted from https://github.com/codrops/ScrollTextMotion (MIT) — Copyright (c) 2009 - 2025 [Codrops](https://codrops.com) */
.ef-stm { width: 100%; }
.ef-stm__wrap { --ef-acc: #e6ff55; position: relative; box-sizing: border-box; width: 100%; height: 24rem; border-radius: 1rem; overflow: hidden; color: #fbf8f1; font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; }
.ef-stm__scroller { position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; outline: none; scrollbar-width: thin; scrollbar-color: var(--ef-acc) transparent; }
.ef-stm__scroller:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc); }
.ef-stm__content { padding: 22rem 1.2rem 14rem; }
.ef-stm__group { display: flex; flex-direction: column; margin-bottom: 9rem; }
.ef-stm__el { display: block; align-self: flex-start; white-space: nowrap; text-transform: uppercase; font-size: 1.35rem; line-height: 1.25; letter-spacing: 0.02em; opacity: 0.55; will-change: transform, opacity; }
.ef-stm__el--xl { font-size: clamp(4rem, 26vw, 8.5rem); line-height: 0.95; font-weight: 700; color: var(--ef-acc); opacity: 1; }
.ef-stm__el--cursor { animation: ef-stm-blink 0.7s linear infinite; color: var(--ef-acc); }
.ef-stm__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.ef-stm__label { position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%); text-align: center; font-size: clamp(2.2rem, 11vw, 4rem); font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--ef-acc); opacity: 0.9; pointer-events: none; transition: opacity 400ms; }
.ef-stm__label.is-away { opacity: 0.12; }
.ef-stm__piece { position: absolute; pointer-events: none; color: var(--ef-acc); opacity: 0.13; will-change: transform; }
.ef-stm__piece--a { right: 6%; top: 8%; width: 5.5rem; transform: rotate(8deg); }
.ef-stm__piece--b { left: 4%; bottom: 10%; width: 5rem; transform: rotate(-10deg); opacity: 0.1; }
.ef-stm__hint { position: absolute; left: 1.2rem; bottom: 0.9rem; font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase; opacity: 0.6; pointer-events: none; }
@keyframes ef-stm-blink { from, to { opacity: 1; } 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .ef-stm__el, .ef-stm__piece { will-change: auto; transform: none; } .ef-stm__el--cursor { animation: none; } .ef-stm__label { transition: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Adapted from https://github.com/codrops/ScrollTextMotion (MIT) — Copyright (c) 2009 - 2025 [Codrops](https://codrops.com)
// Scroll text scramble flip: lines of uppercase text sit in a scroller of their own. As each line travels from the bottom of the view to the
// middle it slides sideways from one column to another (an expo in-out ease driven by scroll position), then slides back as it leaves at the
// top. The first time a line enters view its letters scramble and resolve left to right. Vanilla port: no GSAP, Flip or ScrambleText; the
// slide is a lerp from scroll position and the scramble runs on requestAnimationFrame. Reduced motion shows static text.
// mount(root, { background, accent, label, travel (0.2-1), scramble (0-2, speed multiplier of the scramble time), groups (3-6) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-stm__wrap");
var sc = root.querySelector(".ef-stm__scroller");
var content = root.querySelector(".ef-stm__content");
var labelEl = root.querySelector(".ef-stm__label");
var pieceA = root.querySelector(".ef-stm__piece--a");
var pieceB = root.querySelector(".ef-stm__piece--b");
var opts = {}, items = [], raf = 0, tickRaf = 0, scrambling = [], labelText = "";
var LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// a = starting column, b = column reached at the middle of the view (0 = left edge, 1 = right edge)
var GROUPS = [
{ w: ["Capture", "Verify", "Rebuild"], a: 1, b: 0.28 },
{ w: ["Neural glow", "Data streams", "Spark", "Cold radiance"], a: 0, b: 0.72 },
{ w: ["E"], a: 0, b: 0.3, xl: true, d: 2.5 },
{ w: ["Rebuild complete", "█"], a: 0, b: 0.72, d: 0, cursor: true },
{ w: ["Beacon", "Hidden strata", "Quiet signal"], a: 0.28, b: 1 },
{ w: ["N"], a: 0.72, b: 0.28, xl: true, d: 2.5 }
];
function ease(t) { return t <= 0 ? 0 : t >= 1 ? 1 : t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2; }
function clamp(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function measure() {
var cw = content.clientWidth - parseFloat(getComputedStyle(content).paddingLeft) * 2;
items.forEach(function (it) {
it.top = it.el.offsetTop;
it.h = it.el.offsetHeight;
it.free = Math.max(0, cw - it.el.offsetWidth);
});
}
function setText(it, str) { it.vis.textContent = str; }
function startScramble(it) {
var dur = (it.d == null ? 1 : it.d) * opts.scramble;
if (reduce || dur <= 0) { setText(it, it.text); return; }
it.t0 = performance.now();
it.dur = dur * 1000;
if (scrambling.indexOf(it) < 0) scrambling.push(it);
if (!tickRaf) tickRaf = requestAnimationFrame(tick);
}
function scrambled(text, p) {
var n = text.length, keep = Math.floor(p * n), out = "";
for (var i = 0; i < n; i++) {
var c = text.charAt(i);
out += (i < keep || c === " ") ? c : LETTERS.charAt(Math.floor(Math.random() * LETTERS.length));
}
return out;
}
function tick(now) {
tickRaf = 0;
for (var i = scrambling.length - 1; i >= 0; i--) {
var it = scrambling[i];
var p = clamp((now - it.t0) / it.dur);
setText(it, p >= 1 ? it.text : scrambled(it.text, p));
if (p >= 1) scrambling.splice(i, 1);
}
if (scrambling.length) tickRaf = requestAnimationFrame(tick);
}
function render() {
raf = 0;
var st = sc.scrollTop, vh = Math.max(1, sc.clientHeight);
var max = Math.max(1, sc.scrollHeight - vh), prog = st / max;
for (var i = 0; i < items.length; i++) {
var it = items[i];
var q = (it.top + it.h / 2 - st) / vh; // 0 = top of view, 0.5 = middle, 1 = bottom
var inView = q > 0.02 && q < 0.98;
if (inView && !it.seen) { it.seen = true; startScramble(it); }
else if (!inView && it.seen && (q < -0.2 || q > 1.2)) { it.seen = false; setText(it, it.text); if (it.dur) it.dur = 0; var k = scrambling.indexOf(it); if (k >= 0) scrambling.splice(k, 1); }
if (reduce) continue;
var m = ease(clamp((0.9 - q) / 0.4)) - ease(clamp((0.5 - q) / 0.4));
var col = it.a + (it.b - it.a) * m * opts.travel;
it.el.style.transform = "translate3d(" + (col * it.free).toFixed(1) + "px,0,0)";
if (!it.xl) it.el.style.opacity = (0.42 + 0.58 * m).toFixed(2);
}
labelEl.classList.toggle("is-away", st > vh * 0.25);
if (!reduce) {
pieceA.style.transform = "translate3d(0," + (-prog * 60).toFixed(1) + "px,0) rotate(" + (8 + prog * 40).toFixed(1) + "deg)";
pieceB.style.transform = "translate3d(0," + (prog * 50).toFixed(1) + "px,0) rotate(" + (-10 - prog * 30).toFixed(1) + "deg)";
}
}
function queue() { if (!raf) raf = requestAnimationFrame(render); }
function build() {
scrambling = [];
content.textContent = "";
items = [];
GROUPS.slice(0, opts.groups).forEach(function (g) {
var gd = document.createElement("div");
gd.className = "ef-stm__group";
g.w.forEach(function (txt) {
var el = document.createElement("span");
el.className = "ef-stm__el" + (g.xl ? " ef-stm__el--xl" : "") + (g.cursor && txt === "█" ? " ef-stm__el--cursor" : "");
var vis = document.createElement("span");
vis.setAttribute("aria-hidden", "true");
vis.textContent = txt;
var sr = document.createElement("span");
sr.className = "ef-stm__sr";
sr.textContent = txt === "█" ? "" : txt;
el.appendChild(vis); el.appendChild(sr);
gd.appendChild(el);
items.push({ el: el, vis: vis, text: txt, a: g.a, b: g.b, xl: !!g.xl, d: g.d, seen: false, top: 0, h: 0, free: 0, t0: 0, dur: 0 });
if (reduce) el.style.transform = "none";
});
content.appendChild(gd);
});
measure();
queue();
}
function setLabel() {
if (labelText === opts.label) return;
labelText = opts.label;
var it = { vis: labelEl, text: labelText, d: 1, t0: 0, dur: 0 };
startScramble(it);
if (reduce) labelEl.textContent = labelText;
}
function onScroll() { queue(); }
function onResize() { measure(); queue(); }
sc.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onResize);
var ro = null;
if (window.ResizeObserver) { ro = new ResizeObserver(onResize); ro.observe(sc); }
function update(next) {
var prev = opts;
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", label: "Enrichr", travel: 1, scramble: 1, groups: 6 }, opts, next || {});
opts.travel = Math.max(0.2, Math.min(1, Number(opts.travel) || 1));
opts.scramble = Math.max(0, Math.min(2, Number(opts.scramble)));
if (isNaN(opts.scramble)) opts.scramble = 1;
opts.groups = Math.max(3, Math.min(6, Math.round(opts.groups)));
opts.label = String(opts.label || "Enrichr").slice(0, 14);
wrap.style.background = opts.background;
wrap.style.setProperty("--ef-acc", opts.accent);
if (prev.groups !== opts.groups || !items.length) build(); else queue();
setLabel();
}
update(o);
return {
update: update,
destroy: function () {
sc.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onResize);
if (ro) ro.disconnect();
if (raf) cancelAnimationFrame(raf);
if (tickRaf) cancelAnimationFrame(tickRaf);
raf = 0; tickRaf = 0; scrambling = [];
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","label":"Enrichr","travel":1,"scramble":1,"groups":6});How it works
This is a vanilla port of the idea, with no GSAP, ScrollTrigger, Flip or ScrambleText. The element owns a scroll container, and on every scroll event (batched into one requestAnimationFrame) each line works out where its centre sits in the view, 0 at the top and 1 at the bottom. From 0.9 down to 0.5 an expo in-out ease moves the line from its starting column to its target column, and from 0.5 down to 0.1 the same ease moves it back, so the slide is a pure function of scroll position and reverses when you scroll back. The first time a line enters view its text is replaced by random letters that resolve left to right over a set time, driven by a small rAF loop. The real text stays in a visually hidden span so screen readers never hear the noise.
Accessibility and performance
The scroller is focusable and scrolls with the arrow keys, Page Up and Page Down. Each line keeps its real text in a visually hidden span while the scrambling copy is aria-hidden. With prefers-reduced-motion the lines are static and nothing scrambles or slides. The blinking cursor line is decorative and stops under reduced motion. Keep line text short so scrambling is not distracting.
Scroll handling is batched into one rAF per frame and only writes transform and opacity on the lines. Scramble runs a single rAF loop that stops when no line is mid scramble. Positions are measured once and on resize, so scrolling does no layout reads beyond scrollTop and clientHeight.
| Section | Text |
|---|---|
| Techniques | Text animation, Scroll-driven, Stagger |
| Stack | Vanilla JS, CSS, SVG |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | On-Scroll Text Motion (ScrollTextMotion) by Codrops on github.com. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Why does it use its own scroll container?
So it keeps working inside an overflow hidden box, a card or a preview frame. Listening to window scroll would do nothing there.
Can I change which lines slide where?
Yes. The GROUPS array at the top of element.js holds each group's words, its starting column and its target column as 0 to 1 fractions of the free width.
Can I scramble with other characters?
Edit the LETTERS string at the top of the script. Spaces are always kept.