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 "Letter shuffle menu" for my site using the Enrichr Library element at https://enrichr.dev/elements/letter-shuffle-menu.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/letter-shuffle-menu.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
- speed: 1
- duration: 700
- charset: letters
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/letter-shuffle-menu.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-lsm">
<div class="ef-lsm__wrap"><nav class="ef-lsm__nav" aria-label="Demo menu"><ul class="ef-lsm__list"></ul></nav></div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-lsm { width: 100%; }
.ef-lsm__wrap { box-sizing: border-box; width: 100%; min-height: 18rem; padding: 1.6rem; border-radius: 1rem; display: flex; align-items: center; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; overflow: hidden; }
.ef-lsm__nav { width: 100%; }
.ef-lsm__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; counter-reset: lsm; }
.ef-lsm__item { counter-increment: lsm; }
.ef-lsm__link { position: relative; display: flex; align-items: baseline; gap: 0.9rem; padding: 0.15rem 0; color: var(--ef-ink, #fbf8f1); text-decoration: none; outline: none; font-size: 2.1rem; font-weight: 800; letter-spacing: -0.02em; line-height: 1.1; text-transform: uppercase; }
.ef-lsm__link::before { content: counter(lsm, decimal-leading-zero); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.14em; color: var(--ef-acc, #e6ff55); opacity: 0.7; }
.ef-lsm__txt { display: inline-block; white-space: pre; font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; transition: color 200ms; }
.ef-lsm__link:hover .ef-lsm__txt, .ef-lsm__link:focus-visible .ef-lsm__txt, .ef-lsm__link.is-live .ef-lsm__txt { color: var(--ef-acc, #e6ff55); }
.ef-lsm__link:focus-visible { box-shadow: 0 2px 0 0 var(--ef-acc, #e6ff55); }
.ef-lsm__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .ef-lsm__txt { transition: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Letter shuffle menu: each item scrambles through random characters, then resolves letter by letter into its real label.
// Runs on mount (staggered by item), and again on hover or keyboard focus. The real label is always in a visually hidden node for
// screen readers; the scrambling text is aria-hidden. Reduced motion shows labels immediately with no scrambling.
// mount(root, { background, accent, speed (0.5-3), duration (ms, 200-1500), charset ("letters"|"symbols"|"binary") })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-lsm__wrap");
var list = root.querySelector(".ef-lsm__list");
var LABELS = ["Work", "Studio", "Journal", "About", "Contact"];
var SETS = { letters: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", symbols: "#$%&*+<>?/=~^", binary: "01" };
var opts = {}, items = [], offs = [], timers = [];
function stop(it) { if (it.raf) cancelAnimationFrame(it.raf); it.raf = 0; it.run = 0; }
function clear() {
offs.forEach(function (f) { f(); }); offs = [];
timers.forEach(function (t) { clearTimeout(t); }); timers = [];
items.forEach(stop);
items = [];
}
function shuffle(it) {
stop(it);
var label = it.label;
if (reduce) { it.txt.textContent = label; return; }
var set = SETS[opts.charset] || SETS.letters;
var start = performance.now();
var dur = opts.duration / opts.speed;
var id = ++it.id;
it.run = id;
function frame(now) {
if (it.run !== id) return;
var t = Math.min(1, (now - start) / dur);
var out = "";
for (var i = 0; i < label.length; i++) {
var settle = ((i + 1) / label.length) * 0.85; // left letters resolve first
out += t >= settle ? label.charAt(i) : set.charAt(Math.floor(Math.random() * set.length));
}
it.txt.textContent = t >= 1 ? label : out;
if (t < 1) it.raf = requestAnimationFrame(frame); else { it.raf = 0; it.run = 0; }
}
it.raf = requestAnimationFrame(frame);
}
function build() {
clear();
list.textContent = "";
LABELS.forEach(function (label, i) {
var li = document.createElement("li"); li.className = "ef-lsm__item";
var a = document.createElement("a"); a.className = "ef-lsm__link"; a.href = "#";
var sr = document.createElement("span"); sr.className = "ef-lsm__sr"; sr.textContent = label;
var txt = document.createElement("span"); txt.className = "ef-lsm__txt"; txt.setAttribute("aria-hidden", "true"); txt.textContent = label;
a.appendChild(sr); a.appendChild(txt); li.appendChild(a); list.appendChild(li);
var it = { label: label, txt: txt, raf: 0, run: 0, id: 0 };
items.push(it);
function go() { a.classList.add("is-live"); shuffle(it); }
function off() { a.classList.remove("is-live"); }
function ck(e) { e.preventDefault(); }
a.addEventListener("pointerenter", go);
a.addEventListener("focus", go);
a.addEventListener("pointerleave", off);
a.addEventListener("blur", off);
a.addEventListener("click", ck);
offs.push(function () {
a.removeEventListener("pointerenter", go); a.removeEventListener("focus", go);
a.removeEventListener("pointerleave", off); a.removeEventListener("blur", off); a.removeEventListener("click", ck);
});
if (!reduce) {
txt.textContent = "";
timers.push(setTimeout(function () { shuffle(it); }, (i * 140) / opts.speed));
}
});
}
function update(next) {
var first = !items.length;
var prev = opts;
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", speed: 1, duration: 700, charset: "letters" }, opts, next || {});
opts.speed = Math.max(0.5, Math.min(3, Number(opts.speed) || 1));
opts.duration = Math.max(200, Math.min(1500, Number(opts.duration) || 700));
wrap.style.background = opts.background;
wrap.style.setProperty("--ef-acc", opts.accent);
if (first) build();
else if (prev.charset !== opts.charset) items.forEach(shuffle); // replay so the new character set is visible
}
update(o);
return { update: update, destroy: clear };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","speed":1,"duration":700,"charset":"letters"});How it works
Each menu link holds two spans: a visually hidden one with the real label, and an aria-hidden one that is animated. When a shuffle starts, a requestAnimationFrame loop runs for the set duration. On every frame it rebuilds the visible text, replacing each letter with a random character from the chosen set until that letter's settle point passes, with letters on the left settling first. At the end it writes the true label. On mount the same routine runs on each item with a delay of 140 ms times its index, which creates the stagger. Hover and focus restart it.
Accessibility and performance
Screen readers read the hidden real label, never the scrambled text. Keyboard focus triggers the same animation as hover and shows an underline, and links keep normal focus order. With reduced motion on, the labels show at once and nothing scrambles. The links here use a placeholder href and prevent navigation, so replace those with real URLs.
Only the item being shuffled runs a requestAnimationFrame loop, and it stops itself when done. It writes a short text string per frame and no layout-affecting styles, and a monospace face keeps the width steady so nothing jumps. destroy() cancels all frames and timers and removes every listener.
| Section | Navigation and lists |
|---|---|
| Techniques | Text animation, Stagger, Pointer interaction |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Letter Shuffle Animation for a Menu by Codrops on Codrops. 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 a monospace font?
Random characters have different widths, so a proportional face makes the menu jitter while it scrambles. Monospace keeps every item the same width during the effect.
Can I use my own menu labels?
Yes. Edit the LABELS array, or build the links from your own markup and call the shuffle routine for each link's text span.
Can I use a custom character set?
Yes. Add a key to the SETS object with any string of characters and pass its name as the charset option.