← Navigation

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

  1. Paste the snippet just before </head>.
  2. Add data-marquee-menu-hover to your nav. Each child is a row; style them big.
  3. To scroll different words from a row’s label, give it data-marquee="…".

Options

Add any of these attributes to the same element.

data-colorColour of the band. Default #6a0dad.
data-textColour of the scrolling words. Default #fff.
data-speedHow fast the words scroll, in px per second. Default 70.
data-sepWhat goes between the repeats. Default ✦.
data-autoplayA 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.

1 · Snippet
<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>
2 · Your HTML
<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>