Marquee Menu Hover
A menu of big, bold rows. Point at one and a coloured band slides over it from the edge your pointer came in by, carrying words that scroll sideways like a ticker. Move off and the band slides out the way you left. On a phone, a tap brings the band in.
How to use it
- Paste the snippet just before
</head>. - Add
data-marquee-menu-hoverto your nav. Each child is a row; style them big. - To scroll different words from a row’s label, give it
data-marquee="…".
Options
Add any of these attributes to the same element.
data-color | Colour of the band. Default #6a0dad. |
data-text | Colour of the scrolling words. Default #fff. |
data-speed | How fast the words scroll, in px per second. Default 70. |
data-sep | What goes between the repeats. Default ✦. |
data-autoplay | A pretend pointer runs down the rows while in view, stopping while someone points or taps. Handy for demos. |
The band and its words move in opposite directions as it arrives, so the words seem to be uncovered rather than slid in. The band is hidden from screen readers; the row keeps its own label. Keyboard focus shows the band too, and with reduced motion it appears without sliding.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Marquee Menu Hover: a menu of big rows. Point at a row and a coloured
// band slides in over it from the edge the pointer came in by, carrying the
// row's words scrolling sideways like a ticker. Move off and the band
// slides out the way the pointer left.
// Usage: <nav data-marquee-menu-hover>
// <a href="/work">Work</a>
// <a href="/studio">Studio</a>
// <a href="/contact">Contact</a>
// </nav>
// Each child is a row. Give a row data-marquee="…" to scroll different
// words from its label.
// Optional: data-color (band colour, default #6a0dad), data-text (band
// text colour, default #fff), data-speed (scroll speed in px per second,
// default 70), data-sep (what goes between repeats, default ✦),
// data-autoplay (a pretend pointer runs down the rows while in view,
// stopping when someone points or taps; handy for demos).
// On touch screens a tap shows the band on that row.
var DEFAULTS = { color: '#6a0dad', text: '#fff', speed: 70, sep: '✦', duration: 520 };
var css =
'.mmh{display:flex;flex-direction:column}' +
'.mmh-row{position:relative;display:block;overflow:hidden;-webkit-tap-highlight-color:transparent}' +
'.mmh-band{position:absolute;inset:0;overflow:hidden;pointer-events:none;background:var(--mmh-bg);color:var(--mmh-fg)}' +
'.mmh-inner{position:absolute;inset:0;display:flex;align-items:center}' +
'.mmh-track{display:flex;flex:none;width:max-content;white-space:nowrap;animation:mmh-scroll var(--mmh-time,8s) linear infinite;animation-play-state:paused}' +
'.mmh-on .mmh-track{animation-play-state:running}' +
'.mmh-track span{flex:none;padding:0 .4em}' +
'.mmh-track i{flex:none;font-style:normal;font-size:.5em;align-self:center;opacity:.8;padding:0 .4em}' +
'@keyframes mmh-scroll{to{transform:translateX(-50%)}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// Fill the track with enough copies to cover the row twice over, so
// shifting it by half loops seamlessly.
function fill(row) {
var m = row._mmh, w = row.offsetWidth || 300;
m.track.textContent = '';
var unit = document.createDocumentFragment();
var add = function (parent) {
var t = document.createElement('span');
t.textContent = m.words;
var i = document.createElement('i');
i.textContent = m.sep;
parent.appendChild(t);
parent.appendChild(i);
};
add(m.track);
var one = m.track.scrollWidth || 100;
var n = Math.max(1, Math.ceil(w / one));
m.track.textContent = '';
for (var k = 0; k < n * 2; k++) add(unit);
m.track.appendChild(unit);
m.track.style.setProperty('--mmh-time', (one * n / m.speed) + 's');
}
// Which edge (top or bottom) the pointer is nearer, by its y in the row.
function edge(row, y) {
var r = row.getBoundingClientRect();
return y - r.top < r.height / 2 ? -1 : 1;
}
function show(row, from) {
var m = row._mmh;
if (m.on) return;
m.on = true;
row.classList.add('mmh-on');
anime.utils.remove([m.band, m.inner]);
if (reduced()) { anime.utils.set(m.band, { y: '0%' }); anime.utils.set(m.inner, { y: '0%' }); return; }
// The band and its contents move in opposite directions, so the words
// seem to stay put while the band uncovers them.
anime.animate(m.band, { y: [from * 101 + '%', '0%'], duration: m.duration, ease: 'out(4)' });
anime.animate(m.inner, { y: [-from * 101 + '%', '0%'], duration: m.duration, ease: 'out(4)' });
}
function hide(row, to) {
var m = row._mmh;
if (!m.on) return;
m.on = false;
anime.utils.remove([m.band, m.inner]);
var done = function () { if (!m.on) row.classList.remove('mmh-on'); };
if (reduced()) { anime.utils.set(m.band, { y: '101%' }); done(); return; }
anime.animate(m.band, { y: to * 101 + '%', duration: m.duration * 0.85, ease: 'out(3)', onComplete: done });
anime.animate(m.inner, { y: -to * 101 + '%', duration: m.duration * 0.85, ease: 'out(3)' });
}
function setup(el) {
var d = el.dataset;
var rows = Array.prototype.slice.call(el.children);
if (!rows.length) return;
el.classList.add('mmh');
el.style.setProperty('--mmh-bg', d.color || DEFAULTS.color);
el.style.setProperty('--mmh-fg', d.text || DEFAULTS.text);
var speed = parseFloat(d.speed) || DEFAULTS.speed;
var sep = d.sep !== undefined ? d.sep : DEFAULTS.sep;
var idleUntil = 0;
rows.forEach(function (row) {
row.classList.add('mmh-row');
var band = document.createElement('span');
band.className = 'mmh-band';
band.setAttribute('aria-hidden', 'true');
var inner = document.createElement('span');
inner.className = 'mmh-inner';
var track = document.createElement('span');
track.className = 'mmh-track';
inner.appendChild(track);
band.appendChild(inner);
row.appendChild(band);
anime.utils.set(band, { y: '101%' });
anime.utils.set(inner, { y: '-101%' });
row._mmh = {
band: band, inner: inner, track: track, on: false, speed: speed, sep: sep, duration: DEFAULTS.duration,
words: (row.getAttribute('data-marquee') || row.textContent).trim()
};
fill(row);
row.addEventListener('pointerenter', function (e) {
if (e.pointerType !== 'mouse') return;
rows.forEach(function (r) { if (r !== row) hide(r, 1); });
show(row, edge(row, e.clientY));
});
row.addEventListener('pointerleave', function (e) {
if (e.pointerType !== 'mouse') return;
hide(row, edge(row, e.clientY));
});
// Touch: a tap shows the band on that row (and takes it off the others).
row.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse') return;
idleUntil = Date.now() + 6000;
rows.forEach(function (r) { if (r !== row) hide(r, r.offsetTop < row.offsetTop ? 1 : -1); });
show(row, -1);
});
row.addEventListener('focus', function () { show(row, -1); });
row.addEventListener('blur', function () { hide(row, 1); });
});
var timer = null;
if (typeof ResizeObserver !== 'undefined') {
var lastW = 0;
new ResizeObserver(function () {
if (el.offsetWidth === lastW) return;
lastW = el.offsetWidth;
rows.forEach(fill);
}).observe(el);
}
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
var k = -1;
el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') idleUntil = Infinity; });
el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') idleUntil = Date.now() + 1200; });
// A pretend pointer moving down the menu: each row's band comes in
// from the top and leaves through the bottom as the next one arrives.
var tick = function () {
if (Date.now() < idleUntil) { k = -1; timer = setTimeout(tick, 400); return; }
rows.forEach(function (r, j) { if (j !== k) hide(r, 1); });
if (k >= 0) hide(rows[k], 1);
k = (k + 1) % (rows.length + 1);
if (k === rows.length) { k = -1; timer = setTimeout(tick, 900); return; }
show(rows[k], -1);
timer = setTimeout(tick, 1700);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(tick, 500);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-marquee-menu-hover]').forEach(function (el) {
if (el._mmhSet) return;
el._mmhSet = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><nav data-marquee-menu-hover>
<a href="/work" data-marquee="Case studies">Work</a>
<a href="/studio">Studio</a>
<a href="/contact">Contact</a>
</nav>