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
- Paste the snippet just before
</head>. - Add
data-reaction-pickerto a button. Leave some room above it for the row. - Listen for its
reactevent;event.detail.reactionis the name picked, ornullwhen cleared.
Options
Add any of these attributes to the same element.
data-reactions | Comma-separated “emoji Name” pairs. Default “👍 Like,❤️ Love,😂 Haha,😮 Wow,😢 Sad,😡 Angry”. |
data-color | Label colour once you’ve reacted, when you use your own reactions. Default #6a0dad. |
data-autoplay | A 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.
<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><button data-reaction-picker>Like</button>
<!-- your own set: -->
<button data-reaction-picker data-reactions="🔥 Fire,🎉 Party,🤔 Hmm">React</button>