Upvote Bounce
Press upvote and the arrow squashes, springs up and settles back filled in, leaving a faint ghost that keeps rising. At the same time the count rolls up to the next number. Press again to take your vote back and it rolls down.
How to use it
- Paste the snippet just before
</head>. - Add
data-upvote-bounceto a button, with the current total indata-count. Its text becomes its name for screen readers. - Save the vote when it fires its
voteevent;event.detailhasvotedand the newcount.
Options
Add any of these attributes to the same element.
data-count | The starting total. Default 0. |
data-color | Colour once voted. Default #ea580c. |
data-voted | Start voted (the total already includes your vote). |
data-autoplay | It votes and un-votes itself every couple of seconds while in view, until someone presses it. Handy for demos. |
While voted the button has the class upv-on and aria-pressed="true". Totals of 10,000 and more show as “12.4k”. Size it with font-size. With reduced motion the number just changes.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Upvote Bounce: an upvote button with a running total. Press it and the
// arrow springs up, leaving a faint ghost that keeps rising, then settles
// filled in while the count rolls up to the next number. Press again to
// take the vote back and the count rolls down.
// Usage: <button data-upvote-bounce data-count="41">Upvote</button>
// Optional: data-count (the starting total, default 0), data-color (colour
// once voted, default #ea580c), data-voted (start voted; the total already
// includes it), data-autoplay (it votes and un-votes itself every couple
// of seconds while in view, until someone presses it; handy for demos).
// Events: fires "vote" on the button with event.detail.voted and
// event.detail.count. Its text becomes its name for screen readers.
var DEFAULTS = { count: 0, color: '#ea580c', every: 2200 };
var ARROW = '<svg class="upv-arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3.5 3.5 12.5h5V20.5h7V12.5h5Z"/></svg>';
var css =
':where(.upv){font:inherit;font-weight:700;padding:.55em 1em .55em .8em;border-radius:999px}' +
'.upv{position:relative;display:inline-flex;align-items:center;gap:.45em;cursor:pointer;white-space:nowrap;color:#3f3f46;' +
'background:#fff;border:2px solid #e4e4e7;box-shadow:0 4px 14px rgba(15,15,30,.08);transition:border-color .3s,background-color .3s,color .3s;' +
'-webkit-tap-highlight-color:transparent}' +
'.upv.upv-on{color:var(--upv-c);border-color:color-mix(in srgb,var(--upv-c) 45%,transparent);background:color-mix(in srgb,var(--upv-c) 8%,#fff)}' +
'.upv:focus-visible{outline:3px solid var(--upv-c);outline-offset:3px}' +
'.upv-icon{position:relative;display:inline-block;width:1.2em;height:1.2em;flex:none}' +
'.upv-arrow{position:absolute;inset:0;width:100%;height:100%;overflow:visible;fill:transparent;stroke:currentColor;stroke-width:2.2;stroke-linejoin:round;transform-origin:50% 90%;transition:fill .25s}' +
'.upv-on .upv-arrow{fill:currentColor}' +
'.upv-ghost{fill:currentColor;stroke:none;opacity:0;pointer-events:none}' +
'.upv-num{position:relative;display:inline-grid;overflow:hidden;font-variant-numeric:tabular-nums;padding:.08em 0;' +
'-webkit-mask-image:linear-gradient(transparent,#000 22%,#000 78%,transparent);mask-image:linear-gradient(transparent,#000 22%,#000 78%,transparent)}' +
'.upv-num>span{grid-area:1/1;text-align:center}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function fmt(n) { return n >= 10000 ? (n / 1000).toFixed(n >= 100000 ? 0 : 1) + 'k' : String(n); }
function setup(el) {
var d = el.dataset;
var name = el.textContent.trim() || 'Upvote';
el.classList.add('upv');
el.style.setProperty('--upv-c', d.color || DEFAULTS.color);
el.setAttribute('aria-label', name);
var voted = d.voted !== undefined && d.voted !== 'false';
var count = parseInt(d.count, 10);
if (isNaN(count)) count = DEFAULTS.count;
el.textContent = '';
var icon = document.createElement('span');
icon.className = 'upv-icon';
icon.innerHTML = ARROW + ARROW.replace('upv-arrow', 'upv-arrow upv-ghost');
var arrow = icon.firstChild, ghost = icon.lastChild;
var num = document.createElement('span');
num.className = 'upv-num';
num.setAttribute('aria-live', 'polite');
var cur = document.createElement('span');
cur.textContent = fmt(count);
num.appendChild(cur);
el.appendChild(icon);
el.appendChild(num);
function paint() {
el.classList.toggle('upv-on', voted);
el.setAttribute('aria-pressed', voted ? 'true' : 'false');
}
paint();
// Roll the number: the old one slides out one way, the new one in from the other.
function roll(up) {
var next = document.createElement('span');
next.textContent = fmt(count);
num.appendChild(next);
var old = cur;
cur = next;
if (reduced()) { old.remove(); return; }
var dir = up ? 1 : -1;
anime.animate(old, { y: ['0%', -dir * 105 + '%'], duration: 380, ease: 'inOut(3)', onComplete: function () { old.remove(); } });
anime.animate(next, { y: [dir * 105 + '%', '0%'], duration: 520, ease: 'outBack(2.2)' });
}
function bounce() {
if (reduced()) return;
if (voted) {
anime.animate(arrow, {
keyframes: [
{ y: '18%', scaleY: 0.78, scaleX: 1.15, duration: 110, ease: 'out(2)' },
{ y: '-55%', scaleY: 1.18, scaleX: 0.9, duration: 190, ease: 'out(3)' },
{ y: '0%', scaleY: 1, scaleX: 1, duration: 650, ease: 'outElastic(1, .45)' }
]
});
anime.animate(ghost, { y: ['0%', '-130%'], opacity: [0.45, 0], scale: [1, 0.7], duration: 560, delay: 120, ease: 'out(2)' });
anime.animate(el, { scale: [1, 0.94, 1.04, 1], duration: 520, ease: 'out(2)' });
} else {
anime.animate(arrow, { y: ['0%', '22%', '0%'], scaleY: [1, 0.85, 1], duration: 380, ease: 'out(2)' });
anime.animate(el, { scale: [1, 0.96, 1], duration: 300, ease: 'out(2)' });
}
}
function toggle() {
voted = !voted;
count += voted ? 1 : -1;
paint();
bounce();
roll(voted);
}
var userTouched = false;
el.addEventListener('click', function (e) {
if (e.isTrusted) userTouched = true;
toggle();
el.dispatchEvent(new CustomEvent('vote', { bubbles: true, detail: { voted: voted, count: count } }));
});
if (!reduced() && d.autoplay !== undefined && d.autoplay !== 'false') {
var timer = null;
var loop = function () {
if (userTouched) return;
if (!el.matches(':hover')) toggle();
timer = setTimeout(loop, DEFAULTS.every);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(loop, 700);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-upvote-bounce]').forEach(function (el) {
if (el._upv) return;
el._upv = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><button data-upvote-bounce data-count="41">Upvote</button>
<!-- already voted, in purple: -->
<button data-upvote-bounce data-count="42" data-voted data-color="#6a0dad">Upvote</button>