← Buttons

Reaction Picker

Hover the Like button and a row of emoji reactions pops up above it, each one springing in. Run the pointer along the row and the emoji swell and lift as it passes, like a dock, with a name tag over the closest. Pick one and it flies down into the button. On a phone, press and hold, slide along and let go.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-reaction-picker to a button. Leave some room above it for the row.
  3. Listen for its react event; event.detail.reaction is the name picked, or null when cleared.

Options

Add any of these attributes to the same element.

data-reactionsComma-separated “emoji Name” pairs. Default “👍 Like,❤️ Love,😂 Haha,😮 Wow,😢 Sad,😡 Angry”.
data-colorLabel colour once you’ve reacted, when you use your own reactions. Default #6a0dad.
data-autoplayA pretend pointer opens it and picks a reaction every few seconds while in view, until someone uses it. Handy for demos.

A plain click or tap just likes, and again un-likes. From the keyboard, Arrow Up opens the row, arrows move along it and Escape closes it. With reduced motion the row appears without the swell.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Reaction Picker: hover a "Like" button (or press and hold it on a touch
    // screen) and a row of emoji reactions pops up above it, each one
    // swelling and lifting as the pointer passes over it, like a dock. Pick
    // one and it flies down into the button. A plain click or tap just likes.
    // Usage: <button data-reaction-picker>Like</button>
    // Optional: data-reactions (comma-separated "emoji Name" pairs, default
    // "👍 Like,❤️ Love,😂 Haha,😮 Wow,😢 Sad,😡 Angry"), data-color (label colour
    // once you've reacted, for your own reactions; default #6a0dad),
    // data-autoplay (a pretend pointer opens it and picks reactions every few
    // seconds while in view, until someone uses it; handy for demos).
    // Events: fires "react" on the button with event.detail.reaction (the
    // name, or null when it's cleared).
    // Touch: press and hold, slide along the row and let go on a reaction.
    // Keyboard: Arrow Up opens the row, Escape closes it.
    var DEFAULTS = { reactions: '👍 Like,❤️ Love,😂 Haha,😮 Wow,😢 Sad,😡 Angry', color: '#6a0dad', every: 3800 };
    var COLORS = { Like: '#2563eb', Love: '#e11d48', Haha: '#d97706', Wow: '#d97706', Sad: '#d97706', Angry: '#ea580c' };

    var css =
      '.rxp{position:relative;display:inline-block}' +
      ':where(.rxp-btn){font:inherit;font-weight:600;padding:.6em 1.15em;border-radius:999px;border:1px solid #e4e1ea;background:#fff;color:#4b5563;' +
      'box-shadow:0 2px 8px rgba(15,15,30,.06)}' +
      '.rxp-btn{display:inline-flex;align-items:center;gap:.45em;cursor:pointer;touch-action:none;user-select:none;-webkit-user-select:none;' +
      '-webkit-touch-callout:none;-webkit-tap-highlight-color:transparent;transition:color .25s}' +
      '.rxp-face{display:inline-block;font-size:1.2em;line-height:1;filter:grayscale(1);opacity:.65;transition:filter .25s,opacity .25s}' +
      '.rxp-chosen .rxp-face{filter:none;opacity:1}' +
      '.rxp-pop{position:absolute;left:50%;bottom:calc(100% + .55em);transform:translateX(-50%);z-index:5;pointer-events:none}' +
      '.rxp-open .rxp-pop{pointer-events:auto}' +
      '.rxp-bar{display:flex;gap:.1em;padding:.3em;border-radius:999px;background:#fff;opacity:0;transform-origin:50% 100%;' +
      'box-shadow:0 10px 30px rgba(15,15,30,.16),0 1px 3px rgba(15,15,30,.08)}' +
      '.rxp-item{position:relative;display:grid;place-items:center;width:2.5em;height:2.5em;padding:0;border:0;border-radius:50%;background:none;' +
      'cursor:pointer;font:inherit;-webkit-tap-highlight-color:transparent}' +
      '.rxp-emo{display:block;font-size:1.65em;line-height:1;transform-origin:50% 90%;transition:transform .14s ease-out;will-change:transform}' +
      '.rxp-tip{position:absolute;left:50%;bottom:100%;padding:.2em .55em;border-radius:999px;background:rgba(17,17,27,.85);color:#fff;' +
      'font-size:.7em;font-weight:600;white-space:nowrap;opacity:0;transform:translate(-50%,0);transition:opacity .14s,transform .14s ease-out;pointer-events:none}' +
      '.rxp-fly{position:absolute;left:0;top:0;font-size:1.65em;line-height:1;pointer-events:none;z-index:6}';
    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 inThumb = !!el.closest('[aria-hidden="true"]');
      var custom = !!d.reactions;
      var list = (d.reactions || DEFAULTS.reactions).split(',').map(function (s) {
        s = s.trim();
        var i = s.indexOf(' ');
        return i < 0 ? { emoji: s, name: s } : { emoji: s.slice(0, i), name: s.slice(i + 1).trim() };
      });
      var still = reduced();

      var wrap = document.createElement('span');
      wrap.className = 'rxp';
      el.parentNode.insertBefore(wrap, el);
      wrap.appendChild(el);
      el.classList.add('rxp-btn');
      var baseText = el.textContent.trim() || list[0].name;
      el.textContent = '';
      var face = document.createElement('span');
      face.className = 'rxp-face';
      face.setAttribute('aria-hidden', 'true');
      face.textContent = list[0].emoji;
      var name = document.createElement('span');
      name.textContent = baseText;
      el.appendChild(face);
      el.appendChild(name);
      el.setAttribute('aria-pressed', 'false');

      var pop = document.createElement('span');
      pop.className = 'rxp-pop';
      var bar = document.createElement('span');
      bar.className = 'rxp-bar';
      if (!inThumb) bar.setAttribute('role', 'group');
      bar.setAttribute('aria-label', 'Reactions');
      var items = list.map(function (r, i) {
        var it = document.createElement(inThumb ? 'span' : 'button');
        if (!inThumb) { it.type = 'button'; it.tabIndex = -1; }
        it.className = 'rxp-item';
        it.setAttribute('aria-label', r.name);
        it.innerHTML = '<span class="rxp-tip" aria-hidden="true"></span><span class="rxp-emo" aria-hidden="true"></span>';
        it.firstChild.textContent = r.name;
        it.lastChild.textContent = r.emoji;
        it.addEventListener('click', function (e) { e.stopPropagation(); pick(i); });
        it.addEventListener('keydown', function (e) {
          var n = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
          if (n) { e.preventDefault(); items[(i + n + items.length) % items.length].focus(); }
        });
        it.addEventListener('focus', function () { swellIndex(i); });
        bar.appendChild(it);
        return it;
      });
      pop.appendChild(bar);
      wrap.appendChild(pop);

      var open = false, current = -1, userTouched = false;

      function show() {
        if (open) return;
        open = true;
        wrap.classList.add('rxp-open');
        swell(null);
        var t = still ? 0 : 1;
        anime.animate(bar, { opacity: [0, 1], y: [10, 0], scale: [0.7, 1], duration: 260 * t, ease: 'out(3)' });
        anime.animate(items, { y: [16, 0], scale: [0.3, 1], opacity: [0, 1], duration: 420 * t, delay: anime.stagger(35 * t), ease: 'outBack(2.2)' });
      }
      function hide() {
        if (!open) return;
        open = false;
        wrap.classList.remove('rxp-open');
        anime.animate(bar, { opacity: 0, y: 6, scale: 0.92, duration: still ? 0 : 180, ease: 'in(2)', onComplete: function () { swell(null); } });
      }

      // Dock-style swell: each emoji grows with its closeness to the pointer.
      function swell(x) {
        var nearest = -1, best = 1e9;
        items.forEach(function (it, i) {
          var r = it.getBoundingClientRect();
          var dist = x === null ? 9 : Math.abs(x - (r.left + r.width / 2)) / r.width;
          var s = still ? 1 : 1 + 0.65 * Math.exp(-dist * dist * 1.3);
          it.lastChild.style.transform = 'translateY(' + (-(s - 1) * 0.55) + 'em) scale(' + s + ')';
          if (dist < best) { best = dist; nearest = i; }
        });
        items.forEach(function (it, i) {
          var on = i === nearest && best < 0.6;
          it.firstChild.style.opacity = on ? 1 : 0;
          it.firstChild.style.transform = 'translate(-50%,' + (on ? '-1.6em' : '-1em') + ')';
        });
        return best < 0.6 ? nearest : -1;
      }
      function swellIndex(i) {
        var r = items[i].getBoundingClientRect();
        swell(r.left + r.width / 2);
      }

      function pick(i) {
        userTouched = true;
        choose(i === current ? -1 : i, true);
      }

      function choose(i, fire) {
        var from = i >= 0 ? items[i].lastChild.getBoundingClientRect() : null;
        current = i;
        var r = list[i] || list[0];
        el.classList.toggle('rxp-chosen', i >= 0);
        el.setAttribute('aria-pressed', i >= 0 ? 'true' : 'false');
        el.style.color = i >= 0 ? (custom ? (d.color || DEFAULTS.color) : (COLORS[r.name] || d.color || DEFAULTS.color)) : '';
        name.textContent = i >= 0 ? r.name : baseText;
        hide();
        if (fire) el.dispatchEvent(new CustomEvent('react', { bubbles: true, detail: { reaction: i >= 0 ? r.name : null } }));
        if (still || !from || !open && !from.width) { face.textContent = r.emoji; return; }
        // The chosen emoji flies down into the button, then the button's face pops.
        var w = wrap.getBoundingClientRect(), to = face.getBoundingClientRect();
        var fly = document.createElement('span');
        fly.className = 'rxp-fly';
        fly.setAttribute('aria-hidden', 'true');
        fly.textContent = r.emoji;
        wrap.appendChild(fly);
        var sx = from.left - w.left, sy = from.top - w.top;
        anime.utils.set(fly, { x: sx, y: sy, scale: from.width / fly.offsetWidth });
        anime.animate(face, { scale: 0, duration: 120, ease: 'in(2)' });
        anime.animate(fly, {
          x: to.left - w.left - (fly.offsetWidth - to.width) / 2, y: [sy, sy - 14, to.top - w.top - (fly.offsetHeight - to.height) / 2],
          scale: to.width / fly.offsetWidth, duration: 460, ease: 'inOut(2)',
          onComplete: function () {
            fly.remove();
            face.textContent = r.emoji;
            anime.animate(face, { scale: [0.4, 1.35, 1], rotate: [0, -14, 0], duration: 520, ease: 'out(3)' });
            anime.animate(el, { scale: [1, 1.06, 1], duration: 380, ease: 'out(2)' });
          }
        });
      }

      // Mouse: hover opens after a beat; leaving closes after a beat.
      var openTimer = null, closeTimer = null;
      wrap.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch') return;
        userTouched = true;
        clearTimeout(closeTimer);
        openTimer = setTimeout(show, 260);
      });
      wrap.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch') return;
        clearTimeout(openTimer);
        closeTimer = setTimeout(hide, 380);
      });
      pop.addEventListener('pointermove', function (e) { if (e.pointerType !== 'touch' && open) swell(e.clientX); });
      pop.addEventListener('pointerleave', function () { if (open) swell(null); });

      // Touch: press and hold opens; slide along and let go on one to pick it.
      var holdTimer = null, holding = false, swallow = false;
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        userTouched = true;
        holding = false;
        holdTimer = setTimeout(function () { holding = true; swallow = true; show(); }, 380);
      });
      el.addEventListener('pointermove', function (e) {
        if (!holding) return;
        var b = bar.getBoundingClientRect();
        swell(e.clientY < b.bottom + 30 && e.clientY > b.top - 40 ? e.clientX : null);
      });
      el.addEventListener('pointerup', function (e) {
        clearTimeout(holdTimer);
        if (!holding) return;
        holding = false;
        var b = bar.getBoundingClientRect();
        var i = e.clientY < b.bottom + 30 && e.clientY > b.top - 40 ? swell(e.clientX) : -1;
        if (i >= 0) pick(i); else swell(null);
      });
      el.addEventListener('pointercancel', function () { clearTimeout(holdTimer); holding = false; });
      el.addEventListener('contextmenu', function (e) { if (holding || open) e.preventDefault(); });
      document.addEventListener('pointerdown', function (e) { if (open && !wrap.contains(e.target)) hide(); });

      // A plain click likes (or un-likes).
      el.addEventListener('click', function (e) {
        if (swallow) { swallow = false; return; }
        if (e.isTrusted) userTouched = true;
        choose(current >= 0 ? -1 : 0, true);
        if (current === 0 && !still) anime.animate(face, { scale: [0.5, 1.35, 1], rotate: [0, -14, 0], duration: 520, ease: 'out(3)' });
      });
      el.addEventListener('keydown', function (e) {
        if (e.key === 'ArrowUp') { e.preventDefault(); userTouched = true; show(); items[Math.max(0, current)].focus(); }
      });
      wrap.addEventListener('keydown', function (e) {
        if (e.key === 'Escape' && open) { hide(); el.focus(); }
      });

      if (!still && d.autoplay !== undefined && d.autoplay !== 'false') {
        var timer = null, order = [1, 3, 0, 2, 5, 4], n = 0;
        var cycle = function () {
          if (userTouched) return;
          show();
          var target = order[n++ % order.length];
          var rects = items.map(function (it) { return it.getBoundingClientRect(); });
          var startX = rects[0].left + rects[0].width * 0.3;
          var endX = rects[target].left + rects[target].width / 2;
          var p = { x: startX };
          anime.animate(p, {
            x: [startX, rects[rects.length - 1].left + rects[0].width * 0.5, endX], duration: 1500, delay: 380, ease: 'inOut(2)',
            onUpdate: function () { if (open && !userTouched) swell(p.x); },
            onComplete: function () {
              setTimeout(function () { if (!userTouched && open) choose(target, false); }, 300);
            }
          });
          timer = setTimeout(cycle, DEFAULTS.every);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(cycle, 600);
          else hide();
        }).observe(wrap);
      }
    }

    function init() {
      document.querySelectorAll('[data-reaction-picker]').forEach(function (el) {
        if (el._rxp) return;
        el._rxp = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button data-reaction-picker>Like</button>

<!-- your own set: -->
<button data-reaction-picker data-reactions="🔥 Fire,🎉 Party,🤔 Hmm">React</button>