Copied!
Text

Distributed letters hover

A big headline whose letters scatter evenly across the stage on hover or button press, then spring back into the word.

← 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 "Distributed letters hover" for my site using the Enrichr Library element at https://enrichr.dev/elements/distributed-letters-hover.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/distributed-letters-hover.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
- size: 7
- spread: 1
- stiffness: 120
- rotation: 40

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/distributed-letters-hover.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-dl">
  <div class="ef-dl__wrap">
    <div class="ef-dl__stage">
      <div class="ef-dl__word" role="img" aria-label="Disperse"></div>
    </div>
    <div class="ef-dl__bar">
      <button type="button" class="ef-dl__btn" aria-pressed="false">Scatter letters</button>
      <span class="ef-dl__hint">Hover the stage, or press the button</span>
    </div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-dl { width: 100%; }
.ef-dl__wrap { --sz: 7; box-sizing: border-box; width: 100%; min-height: 19rem; padding: 1rem; border-radius: 1rem; display: flex; flex-direction: column; gap: 0.7rem; overflow: hidden; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #fbf8f1; }
.ef-dl__stage { position: relative; flex: 1; min-height: 14rem; display: flex; align-items: center; justify-content: center; overflow: hidden; border-radius: 0.8rem; }
.ef-dl__word { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; font-size: clamp(2.2rem, calc(var(--sz) * 1rem), 22vw); font-weight: 900; line-height: 1; letter-spacing: -0.02em; text-transform: uppercase; }
.ef-dl__ch { display: inline-block; will-change: transform; color: #fbf8f1; transition: color 300ms; }
.ef-dl__wrap.is-on .ef-dl__ch:nth-child(odd) { color: var(--ef-acc, #e6ff55); }
.ef-dl__bar { display: flex; align-items: center; gap: 0.8rem; flex-wrap: wrap; }
.ef-dl__btn { font: inherit; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.04em; padding: 0.55rem 1rem; border-radius: 999px; border: 1px solid rgba(251, 248, 241, 0.35); background: transparent; color: inherit; cursor: pointer; transition: background 200ms, color 200ms; }
.ef-dl__btn:hover, .ef-dl__btn[aria-pressed="true"] { background: var(--ef-acc, #e6ff55); color: #0c1e18; border-color: transparent; }
.ef-dl__btn:focus-visible { outline: 2px solid var(--ef-acc, #e6ff55); outline-offset: 3px; }
.ef-dl__hint { font-size: 0.72rem; opacity: 0.65; letter-spacing: 0.03em; }
@media (prefers-reduced-motion: reduce) { .ef-dl__ch, .ef-dl__btn { transition: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Distributed letters hover: a big headline whose letters scatter across the stage on hover (or when the button is pressed), each landing in
// its own cell of a shuffled grid so they spread evenly, then spring back into the word. A small per-letter spring runs in rAF only while
// something moves. Reduced motion: letters stay put and the state is shown with colour only. The button is the keyboard and touch route.
// mount(root, { background, accent, size (rem), spread (0-1), stiffness (20-300), rotation (deg) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-dl__wrap");
  var stage = root.querySelector(".ef-dl__stage");
  var word = root.querySelector(".ef-dl__word");
  var btn = root.querySelector(".ef-dl__btn");
  var WORD = "Disperse";
  var opts = {}, L = [], offs = [], raf = 0, last = 0, ro = null;
  var hover = false, pinned = false, scattered = false;

  function on(el, ev, fn) { el.addEventListener(ev, fn); offs.push(function () { el.removeEventListener(ev, fn); }); }
  function stop() { if (raf) cancelAnimationFrame(raf); raf = 0; }
  function rnd(a, b) { return a + Math.random() * (b - a); }

  function homes() {
    L.forEach(function (l) { l.hx = l.el.offsetLeft + l.el.offsetWidth / 2; l.hy = l.el.offsetTop + l.el.offsetHeight / 2; l.hw = l.el.offsetWidth; l.hh = l.el.offsetHeight; });
  }
  // Give each letter its own cell of a shuffled grid; the offset is cell centre minus home, scaled by spread.
  function targets(stagger) {
    var sw = stage.clientWidth, sh = stage.clientHeight, n = L.length, i, j, t;
    var cols = Math.max(1, Math.round(Math.sqrt(n * sw / Math.max(1, sh)))), rows = Math.ceil(n / cols);
    var cw = sw / cols, ch = sh / rows, cells = [];
    for (i = 0; i < cols * rows; i++) cells.push(i);
    for (i = cells.length - 1; i > 0; i--) { j = Math.floor(Math.random() * (i + 1)); t = cells[i]; cells[i] = cells[j]; cells[j] = t; }
    var now = performance.now();
    L.forEach(function (l, k) {
      var c = cells[k], cx = (c % cols + 0.5) * cw + rnd(-0.25, 0.25) * cw, cy = (Math.floor(c / cols) + 0.5) * ch + rnd(-0.25, 0.25) * ch;
      cx = Math.max(l.hw / 2, Math.min(sw - l.hw / 2, cx)); cy = Math.max(l.hh / 2, Math.min(sh - l.hh / 2, cy));
      l.px = (cx - l.hx) * opts.spread;
      l.py = (cy - l.hy) * opts.spread;
      l.pr = rnd(-1, 1) * opts.rotation;
      l.at = stagger ? now + k * 28 : now;
      l.pend = true;
    });
  }
  function release() {
    var now = performance.now();
    L.forEach(function (l, k) { l.px = 0; l.py = 0; l.pr = 0; l.at = now + k * 22; l.pend = true; });
  }
  function paint(l) { l.el.style.transform = "translate3d(" + l.x.toFixed(1) + "px," + l.y.toFixed(1) + "px,0) rotate(" + l.r.toFixed(1) + "deg)"; }
  function tick(t) {
    var dt = Math.min(0.04, (t - last) / 1000); last = t;
    var k = opts.stiffness, c = 2 * Math.sqrt(k) * 0.42, moving = false;
    L.forEach(function (l) {
      if (l.pend && t >= l.at) { l.tx = l.px; l.ty = l.py; l.tr = l.pr; l.pend = false; }
      if (l.pend) { moving = true; return; }
      l.vx += ((l.tx - l.x) * k - l.vx * c) * dt; l.x += l.vx * dt;
      l.vy += ((l.ty - l.y) * k - l.vy * c) * dt; l.y += l.vy * dt;
      l.vr += ((l.tr - l.r) * k - l.vr * c) * dt; l.r += l.vr * dt;
      if (Math.abs(l.tx - l.x) + Math.abs(l.ty - l.y) + Math.abs(l.tr - l.r) > 0.25 || Math.abs(l.vx) + Math.abs(l.vy) + Math.abs(l.vr) > 0.5) moving = true;
      else { l.x = l.tx; l.y = l.ty; l.r = l.tr; l.vx = l.vy = l.vr = 0; }
      paint(l);
    });
    raf = moving ? requestAnimationFrame(tick) : 0;
  }
  function kick() { if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } }
  function sync() {
    var want = hover || pinned;
    btn.setAttribute("aria-pressed", pinned ? "true" : "false");
    btn.textContent = pinned ? "Gather letters" : "Scatter letters";
    if (want === scattered) return;
    scattered = want;
    wrap.classList.toggle("is-on", scattered);
    if (reduce) return;
    if (scattered) targets(true); else release();
    kick();
  }
  function build() {
    word.textContent = ""; L = [];
    WORD.split("").forEach(function (ch) {
      var s = document.createElement("span");
      s.className = "ef-dl__ch"; s.setAttribute("aria-hidden", "true"); s.textContent = ch;
      word.appendChild(s);
      L.push({ el: s, x: 0, y: 0, r: 0, vx: 0, vy: 0, vr: 0, tx: 0, ty: 0, tr: 0, px: 0, py: 0, pr: 0, at: 0, pend: false, hx: 0, hy: 0, hw: 0, hh: 0 });
    });
    homes();
  }
  function relayout() {
    if (!L.length) return;
    L.forEach(function (l) { l.el.style.transform = ""; l.x = l.y = l.r = l.vx = l.vy = l.vr = 0; });
    homes();
    if (scattered && !reduce) { targets(false); kick(); }
  }
  function update(next) {
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", size: 7, spread: 1, stiffness: 120, rotation: 40 }, opts, next || {});
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    wrap.style.setProperty("--sz", String(opts.size));
    if (L.length) { homes(); if (scattered && !reduce) { targets(false); kick(); } }
  }
  update(o);
  build();
  on(stage, "pointerenter", function (e) { if (e.pointerType === "touch") return; hover = true; sync(); });
  on(stage, "pointerleave", function () { hover = false; sync(); });
  on(btn, "click", function () { pinned = !pinned; sync(); });
  on(root, "keydown", function (e) { if (e.key === "Escape" && (pinned || hover)) { pinned = false; hover = false; sync(); } });
  if (typeof ResizeObserver !== "undefined") { ro = new ResizeObserver(relayout); ro.observe(stage); }
  else { window.addEventListener("resize", relayout); offs.push(function () { window.removeEventListener("resize", relayout); }); }
  function destroy() { stop(); offs.forEach(function (f) { f(); }); offs = []; if (ro) ro.disconnect(); ro = null; }
  return { update: update, destroy: destroy };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","size":7,"spread":1,"stiffness":120,"rotation":40});

How it works

The word is split into one inline-block span per letter. On hover the script measures each letter's home centre, shuffles the cells of a grid sized to the stage, and gives every letter its own cell with a little jitter, so the letters spread evenly instead of piling up. Each letter then runs a small spring toward its target offset and rotation, started with a short stagger. Leaving the stage sets every target back to zero and the springs pull the letters home. The loop stops once every letter is at rest.

Accessibility and performance

The headline has an accessible name and the individual letters are hidden from assistive tech, so it reads as one word however scattered it is. The button toggles the scatter with aria-pressed, which gives keyboard and touch users the same effect as hover, and Escape gathers the letters. Under reduced motion the letters never move; the state shows by colour only.

Per frame it writes one transform per letter, and only while something is moving. Letter positions are measured on mount and on resize, not per frame. Keep the word short; the cost grows with the number of letters.

SectionText
TechniquesText animation, Pointer interaction, Stagger
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byDistributed Letters Animation Layout by Manoela Ilic 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.

Split text revealA headline whose words rise out of a mask one after another when it scrolls into view. Letter pop introEach letter overshoots its size and settles, staggered left to right, for a bold page intro. Hover font variantsA text index whose rows flip through serif, mono, outline, wide and italic styles while hovered or focused.

FAQ

Can I change the word?

Yes. Edit the WORD constant in the script and the aria-label in the markup. The grid sizes itself to the letter count.

Why a shuffled grid rather than random positions?

Pure random placement clusters and overlaps. One letter per grid cell, with jitter, keeps the spread even while still looking scattered.

How do I make the spring looser or snappier?

Use the stiffness control. Higher values snap back faster; lower values drift and overshoot more.