Copied!
Navigation and lists

Letter shuffle menu

A menu whose items scramble through random characters and resolve letter by letter into the real label, on load and on hover or focus.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

TextElementVanilla JS + CSS

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.

SectionNavigation and lists
TechniquesText animation, Stagger, Pointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byLetter 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.

Distributed letters hoverA big headline whose letters scatter evenly across the stage on hover or button press, then spring back into the word. Stagger letter swapClick to swap two screens of big type: every character slides up and out in a stagger, the background shifts colour, and the next text rises into place, with a soft cursor circle following the mouse. Split text revealA headline whose words rise out of a mask one after another when it scrolls into view.

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.