Copied!
Navigation and lists

Infinite scroll menu

A vertical looping list of big words you move with the wheel, touch drag or arrow keys, with inertia and scale and fade by distance from the centre.

← Library

Element · Vanilla JS + CSSUpdated November 5, 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 "Infinite scroll menu" for my site using the Enrichr Library element at https://enrichr.dev/elements/infinite-scroll-menu.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/infinite-scroll-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:
- items: Branding, Websites, Motion, Illustration, Packaging, Strategy, Typography
- drift: 0.3
- inertia: 3
- color: #fbf8f1
- accent: #e6ff55

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/infinite-scroll-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-inf" tabindex="0" role="group" aria-label="Scrolling menu. Use the up and down arrow keys, the mouse wheel or drag to move."><ul class="ef-inf__sr"></ul><span class="ef-inf__live" aria-live="polite"></span><div class="ef-inf__vp" aria-hidden="true"></div><span class="ef-inf__hint" aria-hidden="true">Scroll, drag or use the arrow keys</span></div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-inf { container-type: inline-size; position: relative; width: 100%; height: 22rem; overflow: hidden; outline: none; cursor: grab; user-select: none; -webkit-user-select: none; touch-action: pan-x pinch-zoom; box-sizing: border-box; --ef-inf-h: 64px; }
.ef-inf:active { cursor: grabbing; }
.ef-inf:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-inf-a, #e6ff55); }
.ef-inf__vp { position: absolute; inset: 0; -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 24%, #000 76%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0, #000 24%, #000 76%, transparent 100%); }
.ef-inf__it { position: absolute; left: 0; right: 0; top: 50%; height: var(--ef-inf-h); margin-top: calc(var(--ef-inf-h) / -2); display: flex; align-items: center; justify-content: center; gap: 0.5em; font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: calc(var(--ef-inf-h) * 0.72); font-weight: 800; letter-spacing: -0.03em; line-height: 1; white-space: nowrap; color: var(--ef-inf-c, #fbf8f1); will-change: transform, opacity; transition: color 0.25s ease; }
.ef-inf__it.is-on { color: var(--ef-inf-a, #e6ff55); }
.ef-inf__n { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.2em; font-weight: 500; letter-spacing: 0; opacity: 0.6; align-self: flex-start; margin-top: 0.7em; }
.ef-inf__sr, .ef-inf__live { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }
.ef-inf__hint { position: absolute; left: 0; right: 0; bottom: 0.6rem; text-align: center; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.68rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ef-inf-c, #fbf8f1); opacity: 0.45; pointer-events: none; }
@container (max-width: 340px) { .ef-inf__hint { display: none; } }
@media (prefers-reduced-motion: reduce) { .ef-inf__it { transition: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Infinite scroll menu: a vertical loop of big words you move with the mouse wheel, touch or mouse drag, or the arrow keys.
// Motion has inertia and settles with the nearest word on the centre line; words scale and fade by distance from the centre, and the loop has no end.
// Enter fires a bubbling "ef-select" CustomEvent on the root with { index, label }. Clicking a word glides it to the centre.
// mount(root, { items (comma separated), drift (idle auto-scroll in words per second, 0 = off), inertia (glide decay 1-6, lower glides longer), color, accent })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-inf"), vp = box.querySelector(".ef-inf__vp"), sr = box.querySelector(".ef-inf__sr"), live = box.querySelector(".ef-inf__live");
  var opts = {}, labels = [], nodes = [], total = 0, itemH = 64, H = 352;
  var pos = 0, vel = 0, target = null, dragging = false, moved = 0, hover = false, focus = false, lastInput = 0, announced = -1;
  var raf = 0, last = 0, visible = true, io = null, ro = null, lastY = 0, lastT = 0, wheelAt = 0;

  function mod(a, n) { return ((a % n) + n) % n; }
  function wrap(d) { return mod(d + total / 2, total) - total / 2; }
  function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }

  function build() {
    var n = labels.length;
    vp.textContent = ""; sr.textContent = ""; nodes = [];
    if (!n) { total = 0; return; }
    var need = Math.ceil(H / itemH) + 4, copies = Math.max(1, Math.ceil(need / n));
    total = n * copies;
    labels.forEach(function (l) { var li = document.createElement("li"); li.textContent = l; sr.appendChild(li); });
    for (var j = 0; j < total; j++) {
      var el = document.createElement("div"); el.className = "ef-inf__it";
      var num = document.createElement("span"); num.className = "ef-inf__n"; num.textContent = String((j % n) + 1).padStart(2, "0");
      var tx = document.createElement("span"); tx.textContent = labels[j % n];
      el.appendChild(num); el.appendChild(tx);
      el._j = j; el._on = false;
      vp.appendChild(el); nodes.push(el);
    }
  }
  function measure() {
    var w = box.clientWidth || 600; H = box.clientHeight || 352;
    var h = clamp(w * 0.14, 46, 96);
    if (Math.abs(h - itemH) > 0.5) { itemH = h; box.style.setProperty("--ef-inf-h", itemH.toFixed(1) + "px"); }
    var n = labels.length, need = Math.ceil(H / itemH) + 4;
    if (n && n * Math.max(1, Math.ceil(need / n)) !== total) build();
  }
  function render() {
    for (var i = 0; i < nodes.length; i++) {
      var el = nodes[i], d = wrap(el._j - pos), a = Math.abs(d);
      var y = d * itemH, s = Math.max(0.3, 1 - a * 0.3), op = clamp(1.05 - a * 0.42, 0, 1);
      if (a * itemH > H / 2 + itemH) { if (el._hid !== 1) { el.style.opacity = "0"; el._hid = 1; } continue; }
      el._hid = 0;
      el.style.transform = "translate3d(0," + y.toFixed(2) + "px,0) scale(" + s.toFixed(3) + ")";
      el.style.opacity = op.toFixed(3);
      var on = a < 0.5;
      if (on !== el._on) { el._on = on; el.classList.toggle("is-on", on); }
    }
  }
  function idx() { return labels.length ? mod(Math.round(pos), labels.length) : 0; }
  function announce() {
    var i = idx();
    if (i !== announced && labels.length) { announced = i; live.textContent = labels[i] + ", " + (i + 1) + " of " + labels.length; }
  }
  function kick() {
    if (reduce) { render(); announce(); return; }
    if (!raf && visible) { last = performance.now(); raf = requestAnimationFrame(tick); }
  }
  function tick(t) {
    raf = 0;
    var dt = Math.min(0.05, (t - last) / 1000 || 0.016); last = t;
    var drifting = false;
    if (!dragging) {
      if (Math.abs(vel) > 0.6) {
        vel *= Math.exp(-dt * clamp(Number(opts.inertia), 1, 6)); pos += vel * dt; target = null;
      } else if (target === null && Number(opts.drift) > 0 && !hover && !focus && t - lastInput > 1600) {
        vel = 0; pos += Number(opts.drift) * dt; drifting = true;
      } else {
        var goal = target !== null ? target : Math.round(pos);
        vel = 0; pos += (goal - pos) * (1 - Math.exp(-dt * 9));
        if (Math.abs(goal - pos) < 0.002) { pos = goal; target = null; announce(); }
      }
    }
    pos = mod(pos, total || 1);
    render();
    var settled = !dragging && !drifting && vel === 0 && target === null && Math.abs(pos - Math.round(pos)) < 0.002;
    if (!settled || (Number(opts.drift) > 0 && !hover && !focus)) { if (visible) raf = requestAnimationFrame(tick); }
  }
  function nudge(dir) {
    lastInput = performance.now(); vel = 0;
    var base = target !== null ? target : Math.round(pos);
    target = base + dir;
    if (reduce) { pos = mod(target, total || 1); target = null; }
    kick();
  }

  function onWheel(e) {
    if (!total) return;
    e.preventDefault();
    var dy = e.deltaY * (e.deltaMode === 1 ? 16 : 1);
    if (reduce) { var now = performance.now(); if (now - wheelAt > 160 && Math.abs(dy) > 2) { wheelAt = now; nudge(dy > 0 ? 1 : -1); } return; }
    lastInput = performance.now(); target = null;
    vel = clamp(vel + (clamp(dy, -140, 140) / itemH) * 2.4, -14, 14);
    kick();
  }
  function onDown(e) {
    if (e.button > 0) return;
    dragging = true; moved = 0; vel = 0; target = null; lastInput = performance.now();
    lastY = e.clientY; lastT = performance.now();
    try { box.setPointerCapture(e.pointerId); } catch (x) { /* capture is optional */ }
    kick();
  }
  function onMove(e) {
    if (!dragging) return;
    var now = performance.now(), dy = e.clientY - lastY, dts = Math.max(0.004, (now - lastT) / 1000);
    moved += Math.abs(dy); lastY = e.clientY; lastT = now; lastInput = now;
    pos = mod(pos - dy / itemH, total || 1);
    vel = vel * 0.55 + clamp(-dy / itemH / dts, -14, 14) * 0.45;
    if (reduce) render(); else kick();
  }
  function onUp(e) {
    if (!dragging) return;
    dragging = false;
    try { box.releasePointerCapture(e.pointerId); } catch (x) { /* ignore */ }
    if (performance.now() - lastT > 90) vel = 0;
    if (reduce) { pos = mod(Math.round(pos), total || 1); vel = 0; render(); announce(); return; }
    kick();
  }
  function onClick(e) {
    if (moved > 6 || !total) return;
    var r = box.getBoundingClientRect(), d = Math.round((e.clientY - r.top - H / 2) / itemH);
    if (d === 0) return;
    lastInput = performance.now(); vel = 0;
    if (reduce) { pos = mod(Math.round(pos) + d, total); render(); announce(); return; }
    target = Math.round(pos) + d; kick();
  }
  function onKey(e) {
    var k = e.key;
    if (k === "ArrowDown" || k === "ArrowRight") { e.preventDefault(); nudge(1); }
    else if (k === "ArrowUp" || k === "ArrowLeft") { e.preventDefault(); nudge(-1); }
    else if (k === "PageDown") { e.preventDefault(); nudge(3); }
    else if (k === "PageUp") { e.preventDefault(); nudge(-3); }
    else if (k === "Enter" || k === " ") {
      e.preventDefault();
      var i = idx();
      box.dispatchEvent(new CustomEvent("ef-select", { bubbles: true, detail: { index: i, label: labels[i] } }));
    }
  }
  function onEnter() { hover = true; }
  function onLeave() { hover = false; lastInput = performance.now(); kick(); }
  function onFocus() { focus = true; }
  function onBlur() { focus = false; lastInput = performance.now(); kick(); }

  function update(next) {
    var prev = opts.items;
    opts = Object.assign({ items: "Branding, Websites, Motion, Illustration, Packaging, Strategy, Typography", drift: 0.3, inertia: 3, color: "#fbf8f1", accent: "#e6ff55" }, opts, next || {});
    box.style.setProperty("--ef-inf-c", opts.color);
    box.style.setProperty("--ef-inf-a", opts.accent);
    if (prev !== opts.items) {
      labels = opts.items.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
      pos = 0; vel = 0; target = null; announced = -1;
      itemH = 0; measure(); if (!nodes.length) build();
    }
    render(); kick();
  }
  update(o);
  box.addEventListener("wheel", onWheel, { passive: false });
  box.addEventListener("pointerdown", onDown);
  box.addEventListener("pointermove", onMove);
  box.addEventListener("pointerup", onUp);
  box.addEventListener("pointercancel", onUp);
  box.addEventListener("click", onClick);
  box.addEventListener("keydown", onKey);
  box.addEventListener("pointerenter", onEnter);
  box.addEventListener("pointerleave", onLeave);
  box.addEventListener("focus", onFocus);
  box.addEventListener("blur", onBlur);
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { measure(); render(); }); ro.observe(box); }
  if ("IntersectionObserver" in window && !reduce) {
    io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; if (visible) kick(); else { cancelAnimationFrame(raf); raf = 0; } });
    io.observe(root);
  }
  return {
    update: update,
    destroy: function () {
      cancelAnimationFrame(raf); raf = 0;
      if (io) io.disconnect();
      if (ro) ro.disconnect();
      box.removeEventListener("wheel", onWheel);
      box.removeEventListener("pointerdown", onDown);
      box.removeEventListener("pointermove", onMove);
      box.removeEventListener("pointerup", onUp);
      box.removeEventListener("pointercancel", onUp);
      box.removeEventListener("click", onClick);
      box.removeEventListener("keydown", onKey);
      box.removeEventListener("pointerenter", onEnter);
      box.removeEventListener("pointerleave", onLeave);
      box.removeEventListener("focus", onFocus);
      box.removeEventListener("blur", onBlur);
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"items":"Branding, Websites, Motion, Illustration, Packaging, Strategy, Typography","drift":0.3,"inertia":3,"color":"#fbf8f1","accent":"#e6ff55"});

How it works

The list keeps one floating position measured in words. Each word is placed at its distance from that position, wrapped around the length of the list, so the loop has no seam; when the list is shorter than the visible area it is repeated enough times to fill it. Distance from the centre drives the scale, opacity and colour of each word. Wheel and drag input add velocity, a friction step decays it, and once it is slow a spring pulls the position to the nearest whole word so one word always sits on the centre line. Clicking a word glides it to the centre. When nothing has happened for a moment the list drifts slowly by itself, and it stops as soon as the pointer or focus arrives.

Accessibility and performance

The widget is a focusable group. Arrow keys and Page Up and Page Down move it, Enter fires an ef-select event with the centred word, and a polite live region announces the centred word and its position. The looping copies are hidden from assistive technology and a plain list of the real words is included for screen readers. With reduced motion there is no inertia or drift: each key press or wheel gesture jumps one word and drag snaps on release.

Only transform and opacity change, on a handful of absolutely positioned words, from one requestAnimationFrame loop that pauses off screen. The loop idles when the list has settled and drift is off. The wheel handler prevents default while the pointer is over the menu so the page does not scroll underneath it, so leave room for people to scroll past it.

SectionNavigation and lists
TechniquesPointer interaction, Text animation
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byAn Infinitely Scrollable Vertical Menu 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.

Hover font variantsA text index whose rows flip through serif, mono, outline, wide and italic styles while hovered or focused. Distorted link hoverBig links whose letters and underline ripple like heat haze on hover, using an animated SVG turbulence displacement filter. Gooey text hoverA menu of big words with a gooey blob that stretches and follows the pointer from row to row while the active word turns bold.

FAQ

Does it take over page scrolling?

Only while the pointer is over the menu. The wheel moves the list instead of the page there, and touch drags on it do the same. Elsewhere the page scrolls normally.

How do I make a word go somewhere?

Listen for the ef-select event on the root, which carries the index and label of the centred word, or wrap the words in anchors in your own markup.

What if I only have three words?

The list is repeated until it fills the height, so short lists still loop without gaps.