← Gestures

Long-press Menu

Press and hold and a ring fills round your finger. When it closes, a menu blooms out around the spot, each option springing into place on an arc that faces into the box. Slide onto one and let go to pick it, or lift your finger and tap.

Press and hold anywhere

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-long-press-menu to the element to hold, such as a photo or message.
  3. List the options in data-items, separated by commas.
  4. Listen for menuselect to act on the one picked.

Options

Add any of these attributes to the same element.

data-itemsThe options, separated by commas. Default Reply,Copy,Share,Delete.
data-holdHow long to hold before it opens. Default 450 ms.
data-radiusHow far the options sit from your finger, in px. Default 76.
data-feedbackBriefly shows the option picked in the middle.
data-autoplayA pretend finger presses, holds and picks an option every so many ms while in view. Default 2600. A real press pauses it.

menuselect fires with detail.item (the label) and detail.index. Right-click or the keyboard’s menu key opens it too; the arrow keys move between options and Escape closes it.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Long-press Menu: press and hold anywhere on an element and, as a ring
    // fills round your finger, a menu blooms out around it. Slide onto an
    // option and let go to pick it, or lift your finger and tap one. Right-click
    // (or the keyboard's menu key) opens it too.
    // Usage: <div data-long-press-menu data-items="Reply,Copy,Share,Delete">…</div>
    // Optional: data-hold (ms to hold, default 450), data-radius (how far the
    // options sit from your finger, px, default 76), data-feedback (briefly
    // shows the picked option in the middle), data-autoplay (a pretend finger
    // presses, holds and picks every so many ms while in view, default 2600;
    // a real press pauses it).
    // Events: "menuselect" with detail.item (the label) and detail.index.
    var DEFAULTS = { items: 'Reply,Copy,Share,Delete', hold: 450, radius: 76, autoplay: 2600 };

    var css =
      '.lpm{position:relative;touch-action:none;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none}' +
      '.lpm-veil{position:absolute;inset:0;border-radius:inherit;background:rgba(20,12,36,.42);opacity:0;pointer-events:none;z-index:6}' +
      '.lpm-ring{position:absolute;left:0;top:0;width:56px;height:56px;margin:-28px 0 0 -28px;pointer-events:none;z-index:7;opacity:0;overflow:visible}' +
      '.lpm-ring circle{fill:rgba(255,255,255,.18);stroke:#fff;stroke-width:3;stroke-linecap:round}' +
      '.lpm-menu{position:absolute;inset:0;pointer-events:none;z-index:8;margin:0;padding:0}' +
      '.lpm-item{position:absolute;left:0;top:0;display:block;margin:0;padding:8px 13px;border:0;border-radius:999px;background:#fff;color:#2b1748;' +
      'font:600 13px/1.1 system-ui,sans-serif;white-space:nowrap;box-shadow:0 6px 18px rgba(20,10,40,.28);cursor:pointer;opacity:0;pointer-events:none;' +
      'transition:background-color .15s,color .15s}' +
      '.lpm-open .lpm-item{pointer-events:auto}' +
      '.lpm-item.lpm-hot,.lpm-item:focus-visible{background:#6a0dad;color:#fff;outline:none}' +
      '.lpm-toast{position:absolute;left:50%;top:50%;padding:8px 16px;border-radius:999px;background:rgba(20,12,36,.82);color:#fff;' +
      'font:600 14px/1.1 system-ui,sans-serif;white-space:nowrap;pointer-events:none;opacity:0;z-index:9}' +
      '.lpm-finger{position:absolute;left:0;top:0;width:32px;height:32px;margin:-16px 0 0 -16px;border-radius:50%;' +
      'background:rgba(255,255,255,.35);border:2px solid rgba(255,255,255,.9);box-shadow:0 2px 10px rgba(0,0,0,.25);pointer-events:none;opacity:0;z-index:10}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var labels = (d.items || DEFAULTS.items).split(',').map(function (t) { return t.trim(); }).filter(Boolean);
      var hold = +d.hold || DEFAULTS.hold;
      var R = +d.radius || DEFAULTS.radius;
      var thumb = !!el.closest('[aria-hidden="true"]');
      el.classList.add('lpm');
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';

      function make(tag, cls, parent) {
        var n = document.createElement(tag);
        n.className = cls;
        (parent || el).appendChild(n);
        return n;
      }
      var veil = make('div', 'lpm-veil');
      veil.setAttribute('aria-hidden', 'true');
      var ring = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
      ring.setAttribute('class', 'lpm-ring');
      ring.setAttribute('viewBox', '0 0 56 56');
      ring.setAttribute('aria-hidden', 'true');
      ring.innerHTML = '<circle cx="28" cy="28" r="24" transform="rotate(-90 28 28)" pathLength="100" stroke-dasharray="100" stroke-dashoffset="100"/>';
      el.appendChild(ring);
      var arc = ring.firstChild;
      var menu = make('div', 'lpm-menu');
      if (!thumb) { menu.setAttribute('role', 'menu'); menu.setAttribute('aria-label', 'Options'); }
      var items = labels.map(function (t) {
        var b = make(thumb ? 'span' : 'button', 'lpm-item', menu);
        if (!thumb) { b.type = 'button'; b.setAttribute('role', 'menuitem'); b.tabIndex = -1; }
        b.textContent = t;
        return b;
      });
      var toast = d.feedback !== undefined ? make('div', 'lpm-toast') : null;
      if (toast) toast.setAttribute('aria-live', 'polite');

      var s = el._lpm = { open: false, userAt: -1e9 };
      var press = null, hot = -1, holdAnim = null, ringAnim = null;

      function local(cx, cy) {
        var r = el.getBoundingClientRect();
        return { x: cx - r.left, y: cy - r.top };
      }

      function start(x, y) {
        if (s.open) return;
        press = { x: x, y: y, t: performance.now() };
        anime.utils.set(ring, { translateX: x, translateY: y, scale: 0.6 });   // translate: an <svg> has its own x and y
        anime.utils.set(arc, { strokeDashoffset: 100 });
        if (ringAnim) ringAnim.pause();
        anime.animate(ring, { opacity: 1, scale: 1, duration: 160, ease: 'out(2)' });
        ringAnim = anime.animate(arc, { strokeDashoffset: 0, duration: hold, ease: 'linear' });
        if (holdAnim) holdAnim.pause();
        holdAnim = anime.animate(el, { scale: 0.975, duration: hold, ease: 'out(2)' });
        press.timer = setTimeout(function () { openAt(x, y); }, hold);
      }

      function cancel() {
        if (!press) return;
        clearTimeout(press.timer);
        press = null;
        if (ringAnim) ringAnim.pause();
        anime.animate(ring, { opacity: 0, scale: 0.7, duration: 180, ease: 'out(2)' });
        if (!s.open) {
          if (holdAnim) holdAnim.pause();
          anime.animate(el, { scale: 1, duration: 300, ease: 'out(3)' });
        }
      }

      // Lay the options out on an arc that faces into the box, then bloom them.
      function openAt(x, y, keyboard) {
        s.open = true;
        s.openedAt = performance.now();
        s.moved = false;
        el.classList.add('lpm-open');
        if (holdAnim) holdAnim.pause();
        anime.animate(el, { scale: [0.975, 1], duration: 500, ease: 'outElastic(1, .6)' });
        if (navigator.vibrate && !keyboard && !thumb) try { navigator.vibrate(12); } catch (e) {}
        anime.animate(ring, { opacity: 0, scale: 1.5, duration: 260, ease: 'out(2)' });
        anime.animate(veil, { opacity: 1, duration: 220, ease: 'out(2)' });
        var w = el.clientWidth, h = el.clientHeight, n = items.length;
        var r = Math.max(46, Math.min(R, Math.min(w, h) * 0.38));
        var face = Math.atan2(h / 2 - y, w / 2 - x);
        if (Math.hypot(w / 2 - x, h / 2 - y) < r * 0.6) face = -Math.PI / 2;
        var span = n > 1 ? Math.min(Math.PI * 2 * (n - 1) / n, (n - 1) * 0.95) : 0;
        items.forEach(function (b, i) {
          var a = face - span / 2 + (n > 1 ? span * i / (n - 1) : 0);
          var bw = b.offsetWidth, bh = b.offsetHeight;
          var cx = Math.max(bw / 2 + 4, Math.min(w - bw / 2 - 4, x + Math.cos(a) * r));
          var cy = Math.max(bh / 2 + 4, Math.min(h - bh / 2 - 4, y + Math.sin(a) * r));
          b._lpmC = { x: cx, y: cy };
          anime.utils.set(b, { x: x - bw / 2, y: y - bh / 2, scale: 0.2, opacity: 0 });
          anime.animate(b, {
            x: cx - bw / 2, y: cy - bh / 2, scale: 1, opacity: 1,
            delay: reduced() ? 0 : i * 35,
            duration: reduced() ? 0 : 520,
            ease: 'outBack(1.6)',
          });
        });
        setHot(-1);
        if (keyboard && !thumb) items[0].focus();
      }

      function close(picked) {
        if (!s.open) return;
        s.open = false;
        el.classList.remove('lpm-open');
        setHot(-1);
        anime.animate(veil, { opacity: 0, duration: 260, ease: 'out(2)' });
        items.forEach(function (b, i) {
          if (i === picked) {
            anime.animate(b, { scale: [1.15, 0.6], opacity: [1, 0], duration: 380, ease: 'in(2)' });
          } else {
            anime.animate(b, { scale: 0.4, opacity: 0, duration: 200, ease: 'in(2)' });
          }
        });
      }

      function setHot(i) {
        if (i === hot) return;
        if (hot > -1) { items[hot].classList.remove('lpm-hot'); anime.animate(items[hot], { scale: 1, duration: 200, ease: 'out(2)' }); }
        hot = i;
        if (hot > -1) { items[hot].classList.add('lpm-hot'); anime.animate(items[hot], { scale: 1.12, duration: 260, ease: 'outBack(2)' }); }
      }

      function nearest(x, y) {
        var best = -1, bd = 40;
        items.forEach(function (b, i) {
          var c = b._lpmC; if (!c) return;
          var dd = Math.hypot(c.x - x, c.y - y);
          if (dd < bd) { bd = dd; best = i; }
        });
        return best;
      }

      function pick(i) {
        var label = labels[i];
        close(i);
        el.dispatchEvent(new CustomEvent('menuselect', { detail: { item: label, index: i } }));
        if (toast) {
          toast.textContent = label;
          anime.utils.set(toast, { x: '-50%', y: '-50%' });
          anime.createTimeline()
            .add(toast, { opacity: [0, 1], scale: [0.7, 1], duration: 260, ease: 'outBack(2)' })
            .add(toast, { opacity: 0, duration: 300, ease: 'in(2)' }, '+=700');
        }
      }

      function moveTo(x, y) {
        if (press && !s.open) {
          if (Math.hypot(x - press.x, y - press.y) > 10) cancel();
          return;
        }
        if (s.open) {
          s.moved = s.moved || Math.hypot(x - press.x, y - press.y) > 12;
          setHot(nearest(x, y));
        }
      }

      function lift(x, y) {
        if (s.open && press) {
          var i = nearest(x, y);
          press = null;
          if (i > -1 && s.moved) pick(i);
          else if (s.moved) close(-1);
          return;
        }
        cancel();
      }
      s.start = start; s.moveTo = moveTo; s.lift = lift;

      if (!thumb) {
        el.addEventListener('pointerdown', function (e) {
          if (e.button) return;
          s.userAt = performance.now();
          hideFinger();
          if (s.open) {
            if (!e.target.closest('.lpm-item')) close(-1);
            return;
          }
          var p = local(e.clientX, e.clientY);
          el.setPointerCapture(e.pointerId);
          start(p.x, p.y);
        });
        el.addEventListener('pointermove', function (e) {
          if (!press) return;
          var p = local(e.clientX, e.clientY);
          moveTo(p.x, p.y);
        });
        el.addEventListener('pointerup', function (e) {
          var p = local(e.clientX, e.clientY);
          lift(p.x, p.y);
        });
        el.addEventListener('pointercancel', function () { cancel(); });
        el.addEventListener('contextmenu', function (e) {
          e.preventDefault();
          if (s.open || press) return;
          s.userAt = performance.now();
          var kb = !e.clientX && !e.clientY;
          var p = kb ? { x: el.clientWidth / 2, y: el.clientHeight / 2 } : local(e.clientX, e.clientY);
          press = null;
          openAt(p.x, p.y, kb);
        });
        items.forEach(function (b, i) {
          b.addEventListener('click', function () { if (s.open) pick(i); });
        });
        el.addEventListener('keydown', function (e) {
          if (!s.open) return;
          var at = items.indexOf(document.activeElement);
          if (e.key === 'Escape') { close(-1); el.focus(); }
          else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault(); items[(at + 1) % items.length].focus(); }
          else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault(); items[(at - 1 + items.length) % items.length].focus(); }
        });
        document.addEventListener('pointerdown', function (e) { if (s.open && !el.contains(e.target)) close(-1); });
      }

      if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
      var every = +d.autoplay || DEFAULTS.autoplay;
      var finger = make('span', 'lpm-finger');
      finger.setAttribute('aria-hidden', 'true');
      function hideFinger() { anime.animate(finger, { opacity: 0, duration: 200 }); }
      var visible = false, timer = 0;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        clearTimeout(timer);
        if (visible) timer = setTimeout(play, 700);
      }).observe(el);

      function play() {
        if (!visible) return;
        if (performance.now() - s.userAt < 5000 || press || s.open) { timer = setTimeout(play, 900); return; }
        var w = el.clientWidth, h = el.clientHeight;
        var x = w * (0.3 + Math.random() * 0.4), y = h * (0.35 + Math.random() * 0.3);
        anime.utils.set(finger, { x: x, y: y });
        anime.animate(finger, { opacity: [0, 1], scale: [1.4, 0.9], duration: 200, ease: 'out(2)' });
        timer = setTimeout(function () {
          if (performance.now() - s.userAt < 5000) return next();
          start(x, y);
          timer = setTimeout(function () {
            if (!s.open || performance.now() - s.userAt < 5000) return next();
            var i = Math.floor(Math.random() * items.length), c = items[i]._lpmC;
            var p = { x: x, y: y };
            anime.animate(p, {
              x: c.x, y: c.y, duration: 520, delay: 380, ease: 'inOut(2)',
              onUpdate: function () {
                if (!s.open) return;
                moveTo(p.x, p.y);
                anime.utils.set(finger, { x: p.x, y: p.y });
              },
              onComplete: function () {
                timer = setTimeout(function () {
                  if (s.open) lift(p.x, p.y);
                  anime.animate(finger, { opacity: 0, scale: 1.2, duration: 220, ease: 'out(2)' });
                  timer = setTimeout(play, every);
                }, 260);
              },
            });
          }, hold + 60);
        }, 220);
      }
      function next() { hideFinger(); cancel(); timer = setTimeout(play, 900); }
    }

    function init() {
      document.querySelectorAll('[data-long-press-menu]').forEach(function (el) {
        if (el._lpm) return;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div class="message" data-long-press-menu data-items="Reply,Copy,Forward,Delete">
  See you at 6!
</div>

<script>
  document.querySelector('[data-long-press-menu]').addEventListener('menuselect', (e) => {
    console.log(e.detail.item);
  });
</script>