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 "Marquee hover menu" for my site using the Enrichr Library element at https://enrichr.dev/elements/marquee-hover-menu.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/marquee-hover-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: Discover, Experiment, Create, Repeat
- speed: 9
- direction: aware
- accent: #e6ff55
- background: #0c1e18
- ink: #fbf8f1
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/marquee-hover-menu.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<nav class="ef-mqm" aria-label="Menu">
<ul class="ef-mqm__list"></ul>
</nav>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-mqm { --ef-mqm-bg: #0c1e18; --ef-mqm-acc: #e6ff55; --ef-mqm-ink: #fbf8f1; --ef-mqm-speed: 9s; --ef-mqm-e: cubic-bezier(0.22, 1, 0.36, 1); width: 100%; box-sizing: border-box; padding: 0.6rem 1.2rem; border-radius: 1rem; background: var(--ef-mqm-bg); color: var(--ef-mqm-ink); font-family: "Helvetica Neue", Arial, "Segoe UI", system-ui, sans-serif; container-type: inline-size; }
.ef-mqm__list { list-style: none; margin: 0; padding: 0; }
.ef-mqm__item { border-top: 1px solid color-mix(in srgb, var(--ef-mqm-ink) 22%, transparent); }
.ef-mqm__item:last-child { border-bottom: 1px solid color-mix(in srgb, var(--ef-mqm-ink) 22%, transparent); }
.ef-mqm__row { position: relative; display: block; overflow: hidden; color: inherit; text-decoration: none; outline: none; }
.ef-mqm__static { display: flex; align-items: center; gap: 1.2rem; padding: 0.9rem 0.4rem; font-size: clamp(1.8rem, 7.5cqi, 4.4rem); font-weight: 800; line-height: 1.05; letter-spacing: -0.05em; }
.ef-mqm__n { flex: none; font: 600 0.72rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: 0.1em; opacity: 0.7; }
.ef-mqm__arr { margin-left: auto; width: 0.55em; height: 0.55em; flex: none; }
.ef-mqm__band { position: absolute; inset: 0; overflow: hidden; background: var(--ef-mqm-acc); color: var(--ef-mqm-bg); transform: translate3d(0, 101%, 0); transition: transform 450ms var(--ef-mqm-e); pointer-events: none; }
.ef-mqm__inner { position: absolute; inset: 0; display: flex; align-items: center; transform: translate3d(0, -101%, 0); transition: transform 450ms var(--ef-mqm-e); }
.ef-mqm__track { display: flex; flex: none; white-space: nowrap; animation: ef-mqm-run var(--ef-mqm-speed) linear infinite; animation-play-state: paused; will-change: transform; }
.ef-mqm__row.is-on .ef-mqm__track { animation-play-state: running; }
.ef-mqm__chunk { display: flex; flex: none; align-items: center; font-size: clamp(2rem, 8.5cqi, 5rem); font-weight: 900; letter-spacing: -0.05em; }
.ef-mqm__word { padding: 0 0.35em; }
.ef-mqm__sep { width: 0.32em; height: 0.32em; flex: none; border-radius: 50%; background: currentColor; }
.ef-mqm__row:focus-visible { box-shadow: inset 0 0 0 3px var(--ef-mqm-ink); }
@keyframes ef-mqm-run { to { transform: translate3d(-50%, 0, 0); } }
@media (prefers-reduced-motion: reduce) { .ef-mqm__band, .ef-mqm__inner { transition: none; } .ef-mqm__track { animation: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Marquee hover menu: big menu rows where a coloured band slides in on hover or focus, carrying the same word as an endless
// ticker. The band enters from the edge the pointer came in through and leaves through the edge it went out of, because
// the band and its content slide in opposite directions (a reveal rather than a push). The ticker is a CSS animation that
// only runs while its band is showing. Rows are real links; mouse and pen get the effect, touch just follows the link.
// mount(root, { items (comma separated, "Label" or "Label=url"), speed (seconds per ticker loop), direction ("aware" or "bottom"),
// accent, background, ink })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var nav = root.querySelector(".ef-mqm");
var list = root.querySelector(".ef-mqm__list");
var opts = {}, rows = [], ro = null, lastW = 0;
var NS = "http://www.w3.org/2000/svg";
function arrow() {
var s = document.createElementNS(NS, "svg");
s.setAttribute("viewBox", "0 0 24 24"); s.setAttribute("class", "ef-mqm__arr"); s.setAttribute("aria-hidden", "true"); s.setAttribute("focusable", "false");
s.innerHTML = '<path d="M5 19L19 5M8 5h11v11" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>';
return s;
}
function chunk(word, copies) {
var c = document.createElement("span"); c.className = "ef-mqm__chunk";
for (var i = 0; i < copies; i++) {
var w = document.createElement("span"); w.className = "ef-mqm__word"; w.textContent = word;
var d = document.createElement("span"); d.className = "ef-mqm__sep";
c.appendChild(w); c.appendChild(d);
}
return c;
}
// Fill each ticker so one half is at least as wide as the row; the track then loops by sliding exactly one half.
function fill(r) {
r.track.textContent = "";
var probe = chunk(r.word, 1);
r.track.appendChild(probe);
var one = probe.getBoundingClientRect().width || 200, need = r.el.getBoundingClientRect().width || 600;
var copies = Math.max(2, Math.ceil(need / one) + 1);
r.track.textContent = "";
r.track.appendChild(chunk(r.word, copies));
r.track.appendChild(chunk(r.word, copies));
}
function slide(r, y) {
r.band.style.transform = "translate3d(0," + y + "%,0)";
r.inner.style.transform = "translate3d(0," + (-y) + "%,0)";
}
function show(r, fromTop) {
var y = fromTop ? -101 : 101;
r.band.style.transition = r.inner.style.transition = "none";
slide(r, y);
void r.band.offsetWidth;
r.band.style.transition = r.inner.style.transition = "";
slide(r, 0);
r.el.classList.add("is-on");
}
function hide(r, toTop) {
slide(r, toTop ? -101 : 101);
r.el.classList.remove("is-on");
}
function edgeTop(r, e) {
if (opts.direction !== "aware") return false;
var b = r.el.getBoundingClientRect();
return e.clientY - b.top < b.height / 2;
}
function build() {
list.textContent = "";
rows = [];
var items = String(opts.items).split(",").map(function (s) {
var k = s.indexOf("="); return k < 0 ? { t: s.trim(), href: "#" } : { t: s.slice(0, k).trim(), href: s.slice(k + 1).trim() || "#" };
}).filter(function (i) { return i.t; }).slice(0, 8);
if (!items.length) items = [{ t: "Menu", href: "#" }];
items.forEach(function (it, i) {
var li = document.createElement("li"); li.className = "ef-mqm__item";
var a = document.createElement("a"); a.className = "ef-mqm__row"; a.href = it.href;
var st = document.createElement("span"); st.className = "ef-mqm__static";
var n = document.createElement("span"); n.className = "ef-mqm__n"; n.setAttribute("aria-hidden", "true"); n.textContent = String(i + 1).padStart(2, "0");
var t = document.createElement("span"); t.className = "ef-mqm__t"; t.textContent = it.t;
st.appendChild(n); st.appendChild(t); st.appendChild(arrow());
var band = document.createElement("span"); band.className = "ef-mqm__band"; band.setAttribute("aria-hidden", "true");
var inner = document.createElement("span"); inner.className = "ef-mqm__inner";
var track = document.createElement("span"); track.className = "ef-mqm__track";
inner.appendChild(track); band.appendChild(inner);
a.appendChild(st); a.appendChild(band);
li.appendChild(a); list.appendChild(li);
var r = { el: a, band: band, inner: inner, track: track, word: it.t };
rows.push(r);
a.addEventListener("pointerenter", function (e) { if (e.pointerType !== "touch") show(r, edgeTop(r, e)); });
a.addEventListener("pointerleave", function (e) { if (e.pointerType !== "touch") hide(r, edgeTop(r, e)); });
a.addEventListener("focus", function () { if (!r.el.classList.contains("is-on")) show(r, false); });
a.addEventListener("blur", function () { hide(r, false); });
a.addEventListener("click", function (e) { if (it.href === "#") e.preventDefault(); });
});
rows.forEach(fill);
lastW = list.getBoundingClientRect().width;
}
function update(next) {
opts = Object.assign({ items: "Discover, Experiment, Create, Repeat", speed: 9, direction: "aware", accent: "#e6ff55", background: "#0c1e18", ink: "#fbf8f1" }, opts, next || {});
nav.style.setProperty("--ef-mqm-speed", Math.max(2, Number(opts.speed) || 9) + "s");
nav.style.setProperty("--ef-mqm-acc", opts.accent);
nav.style.setProperty("--ef-mqm-bg", opts.background);
nav.style.setProperty("--ef-mqm-ink", opts.ink);
build();
}
update(o);
if ("ResizeObserver" in window) {
ro = new ResizeObserver(function () { var w = list.getBoundingClientRect().width; if (Math.abs(w - lastW) > 40) { lastW = w; rows.forEach(fill); } });
ro.observe(list);
}
return { update: update, destroy: function () { if (ro) ro.disconnect(); list.textContent = ""; rows = []; } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"items":"Discover, Experiment, Create, Repeat","speed":9,"direction":"aware","accent":"#e6ff55","background":"#0c1e18","ink":"#fbf8f1"});How it works
Each row is a link holding the static label and a hidden band. On pointer enter the script checks whether the pointer came in through the top or bottom half of the row, parks the band just outside that edge and its content just outside the opposite edge, then transitions both to zero. Because they move in opposite directions the band reveals the ticker rather than pushing it. On leave it exits through the edge the pointer left by. The ticker is two identical halves, each filled with enough copies of the word to cover the row, and a CSS animation slides the track by exactly one half so it loops without a seam. It only runs while its band is showing.
Accessibility and performance
Rows are real links in a list inside a labelled nav, and the band is aria-hidden, so screen readers hear each label once. Keyboard focus shows the band too, with a visible focus ring. Touch taps just follow the link. With prefers-reduced-motion the band appears without sliding and the ticker stays still.
Only transforms animate, and each ticker is paused unless its row is hovered or focused. Rows are measured once, and again only when the menu's width changes by more than 40 pixels.
| Section | Navigation and lists |
|---|---|
| Techniques | Pointer interaction, Text animation |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | GSAP Infinite Marquee Menu by Enrichr on CodePen. 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
How do I set the link targets?
Write items as Label=url, for example Work=/work, Studio=/studio. Items without a url get # and do nothing when clicked.
Why does the band come from different sides?
It follows the pointer: entering from above slides it down, entering from below slides it up. Set the direction to Always from below for a fixed direction.
Will a long label overflow?
The static label wraps like normal text. The ticker always fills the row, because the number of copies is worked out from the row width.