XP Bar
An experience bar fills as points come in. Each gain pops up as “+45 XP” and the bar and counter run up to match. When a gain is more than the level needs, the bar fills to the brim, flashes, the level badge spins up a number with a “Level up!”, and the bar rolls over to carry on with what’s left.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-xp-bar></div>, with the player’s level and points asdata-levelanddata-xp. It fills the width it’s given. - List the points to add in
data-gains, or callanimacious.xpBar(el, 50)whenever points are earned.
Options
Add any of these attributes to the same element.
data-level | Starting level. Default 1. |
data-xp | Starting points. Default 0. |
data-max | Points the first level needs. Default 100. |
data-growth | Each level needs this many times more points than the last. Default 1.2. |
data-gains | Comma-separated points to add in turn when it scrolls into view. Default 30,45,60. |
data-step | Pause between gains. Default 1300 ms. |
data-color | Fill colour, or two comma-separated colours for a gradient. Default #a855f7,#ec4899. |
data-loop | Start over after the last gain. |
data-hold | Pause before starting over. Default 2200 ms. |
The track is a labelled progress bar that follows the points. With reduced motion the bar jumps straight to each new value.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// XP Bar: an experience bar that fills as points come in. Each gain pops up
// as "+40 XP" and the bar and counter run up to match. When a gain is more
// than the level needs, the bar fills to the brim, flashes, the level badge
// ticks up with a "Level up!", and the bar rolls over and carries on with
// what's left, towards a slightly bigger target.
// Usage: <div data-xp-bar data-level="4" data-xp="40" data-max="100" data-gains="25,45,60"></div>
// Optional: data-level (starting level, default 1), data-xp (starting
// points, default 0), data-max (points the first level needs, default 100),
// data-growth (each level needs this many times more, default 1.2),
// data-gains (comma-separated points to add in turn, default "30,45,60"),
// data-step (ms between gains, default 1300), data-color (fill colour, or
// two comma-separated for a gradient, default "#a855f7,#ec4899"),
// data-loop (start over after the last gain), data-hold (ms before
// looping, default 2200).
// Script use: animacious.xpBar(el, 50) adds 50 points.
var DEFAULTS = { level: 1, xp: 0, max: 100, growth: 1.2, gains: '30,45,60', step: 1300, color: '#a855f7,#ec4899', hold: 2200 };
var css =
'.xpb{display:flex;align-items:center;gap:.8em;width:100%;line-height:1.2;text-align:left}' +
'.xpb-badge{position:relative;flex:none;display:grid;place-items:center;width:2.6em;height:2.6em;border-radius:50%;' +
'background:var(--xpb-fill);color:#fff;box-shadow:0 .3em .8em -.3em var(--xpb-a)}' +
'.xpb-badge small{position:absolute;top:.42em;font-size:.48em;font-weight:700;letter-spacing:.08em;opacity:.85}' +
'.xpb-num{display:block;margin-top:.35em;font-size:1.1em;font-weight:800;font-variant-numeric:tabular-nums}' +
'.xpb-main{position:relative;flex:1;min-width:0;display:grid;gap:.45em}' +
'.xpb-row{display:flex;justify-content:space-between;align-items:baseline;gap:.6em;white-space:nowrap}' +
'.xpb-title{font-weight:800}' +
'.xpb-count{font-size:.85em;font-variant-numeric:tabular-nums;opacity:.65}' +
'.xpb-track{position:relative;height:.8em;border-radius:.4em;background:color-mix(in srgb,currentColor 12%,transparent);overflow:hidden}' +
'.xpb-fill{position:absolute;left:0;top:0;bottom:0;width:0;border-radius:inherit;background:var(--xpb-fill)}' +
'.xpb-fill::after{content:"";position:absolute;inset:0 0 50% 0;border-radius:inherit;background:rgba(255,255,255,.28)}' +
'.xpb-flash{position:absolute;inset:0;background:#fff;opacity:0;border-radius:inherit}' +
'.xpb-pop{position:absolute;right:0;bottom:100%;font-size:.8em;font-weight:800;color:var(--xpb-a);opacity:0;white-space:nowrap;pointer-events:none}' +
'.xpb-up{position:absolute;left:-4em;right:-4em;margin:0 auto;width:max-content;bottom:110%;font-size:.78em;font-weight:800;letter-spacing:.03em;padding:.25em .7em;border-radius:999px;' +
'background:var(--xpb-fill);color:#fff;opacity:0;white-space:nowrap;pointer-events:none}';
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 cols = (d.color || DEFAULTS.color).split(',').map(function (c) { return c.trim(); });
el.classList.add('xpb');
el.style.setProperty('--xpb-a', cols[0]);
el.style.setProperty('--xpb-fill', cols.length > 1 ? 'linear-gradient(90deg,' + cols.join(',') + ')' : cols[0]);
el.innerHTML =
'<span class="xpb-badge" aria-hidden="true"><small>LVL</small><span class="xpb-num"></span><span class="xpb-up">Level up!</span></span>' +
'<span class="xpb-main">' +
'<span class="xpb-row"><span class="xpb-title"></span><span class="xpb-count"></span></span>' +
'<span class="xpb-track" role="progressbar" aria-valuemin="0"><span class="xpb-fill"></span><span class="xpb-flash"></span></span>' +
'<span class="xpb-pop" aria-hidden="true"></span>' +
'</span>';
var s = el._xpb = {
badge: el.querySelector('.xpb-badge'), num: el.querySelector('.xpb-num'), up: el.querySelector('.xpb-up'),
title: el.querySelector('.xpb-title'), count: el.querySelector('.xpb-count'),
track: el.querySelector('.xpb-track'), fill: el.querySelector('.xpb-fill'),
flash: el.querySelector('.xpb-flash'), pop: el.querySelector('.xpb-pop'),
growth: Math.max(1, +d.growth || DEFAULTS.growth),
gains: (d.gains || DEFAULTS.gains).split(',').map(Number).filter(function (n) { return n > 0; }),
visible: false, pending: null, timer: null, run: 0, busy: false, queue: []
};
reset(el);
}
function reset(el) {
var s = el._xpb, d = el.dataset;
s.level = Math.max(1, Math.round(+d.level || DEFAULTS.level));
s.max = Math.max(1, Math.round(+d.max || DEFAULTS.max));
s.xp = Math.min(s.max - 1, Math.max(0, Math.round(+d.xp || DEFAULTS.xp)));
s.shown = s.xp;
paint(s);
s.fill.style.width = (s.xp / s.max) * 100 + '%';
}
function paint(s) {
s.num.textContent = s.level;
s.title.textContent = 'Level ' + s.level;
s.count.textContent = Math.round(s.shown) + ' / ' + s.max + ' XP';
s.track.setAttribute('aria-valuemax', s.max);
s.track.setAttribute('aria-valuenow', Math.round(s.xp));
s.track.setAttribute('aria-label', 'Level ' + s.level + ', ' + Math.round(s.xp) + ' of ' + s.max + ' XP');
}
function nextMax(s) { return Math.round((s.max * s.growth) / 10) * 10 || s.max; }
// Run the bar (and counter) from where it is to `to` points.
function fillTo(s, to, duration, done) {
anime.animate(s, {
shown: to, duration: duration, ease: 'inOut(2)',
onUpdate: function () { s.count.textContent = Math.round(s.shown) + ' / ' + s.max + ' XP'; }
});
anime.animate(s.fill, { width: (to / s.max) * 100 + '%', duration: duration, ease: 'inOut(2)', onComplete: done });
}
function gain(el, points, done) {
var s = el._xpb;
s.pop.textContent = '+' + points + ' XP';
anime.animate(s.pop, { opacity: [{ to: 1, duration: 150 }, { to: 0, duration: 400, delay: 650 }], y: ['0.4em', '-0.9em'], duration: 1200, ease: 'out(2)' });
if (reduced()) {
s.xp += points;
while (s.xp >= s.max) { s.xp -= s.max; s.level++; s.max = nextMax(s); }
s.shown = s.xp;
s.fill.style.width = (s.xp / s.max) * 100 + '%';
paint(s);
if (done) done();
return;
}
(function step(left) {
var room = s.max - s.xp;
if (left < room) {
s.xp += left;
paint(s);
fillTo(s, s.xp, 300 + left * 6, done);
return;
}
// Fill to the brim, flash, level up, roll over.
s.xp = s.max;
fillTo(s, s.max, 300 + room * 5, function () {
anime.animate(s.flash, { opacity: [0.85, 0], duration: 600, ease: 'out(2)' });
anime.animate(s.track, { scaleY: [{ to: 1.5, duration: 120 }, { to: 1, duration: 500, ease: 'outElastic(1, .5)' }] });
s.level++;
s.max = nextMax(s);
s.xp = 0;
s.shown = 0;
paint(s);
anime.animate(s.badge, { scale: [{ to: 1.3, duration: 160, ease: 'out(2)' }, { to: 1, duration: 650, ease: 'outElastic(1, .45)' }], rotate: ['0deg', '360deg'], duration: 700, ease: 'out(3)' });
anime.animate(s.up, {
opacity: [{ to: 1, duration: 150 }, { to: 0, duration: 350, delay: 900 }],
y: ['0em', '-0.9em'], scale: [0.6, 1], duration: 1400, ease: 'outBack(2)'
});
anime.utils.set(s.fill, { width: '0%' });
setTimeout(function () { step(left - room); }, 420);
});
})(points);
}
// Wait for the bar to be on screen before carrying on.
function later(s, fn, ms) {
clearTimeout(s.timer);
s.timer = setTimeout(function () { if (s.visible) fn(); else s.pending = fn; }, ms);
}
function play(el) {
var s = el._xpb, d = el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var stepMs = +d.step || DEFAULTS.step;
var i = 0;
(function next() {
if (i >= s.gains.length) {
if (!loop) return;
later(s, function () {
// Wind back to the start.
anime.animate(el, { opacity: [1, 0], duration: 300, onComplete: function () {
reset(el);
anime.animate(el, { opacity: [0, 1], duration: 400 });
i = 0;
later(s, next, 900);
} });
}, +d.hold || DEFAULTS.hold);
return;
}
gain(el, s.gains[i++], function () { later(s, next, stepMs); });
})();
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._xpb;
s.visible = e.isIntersecting;
if (!s.visible) return;
if (!s.started) { s.started = true; later(s, function () { play(e.target); }, 500); }
else if (s.pending) { var fn = s.pending; s.pending = null; fn(); }
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-xp-bar]').forEach(function (el) {
if (el._xpb) return;
setup(el);
if (!reduced()) io.observe(el);
});
}
window.animacious = window.animacious || {};
window.animacious.xpBar = function (el, points) { if (el._xpb && points > 0) gain(el, Math.round(points)); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-xp-bar data-level="4" data-xp="40" data-gains="25,45,70"></div>
<!-- in your own colours, adding points from code: -->
<div id="xp" data-xp-bar data-gains="0" data-color="#0ea5e9,#22c55e"></div>
<script>animacious.xpBar(document.getElementById("xp"), 50);</script>