Loyalty Program Tier Progress — Free Multi-Tier Rewards Widget
Loyalty Program Tier Progress · Dashboards · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<section class="ltp-wrap">
<div class="ltp-card">
<div class="ltp-head">
<div>
<span class="ltp-eyebrow">Current tier</span>
<h2 id="ltpTierName">Silver</h2>
</div>
<div class="ltp-badge" id="ltpBadge">🥈</div>
</div>
<div class="ltp-points-row">
<span id="ltpPointsEarned">2,340</span>
<span class="ltp-points-label">points this cycle</span>
</div>
<div class="ltp-progress-head">
<span id="ltpNextLabel">1,160 pts to Gold</span>
<span id="ltpPct">67%</span>
</div>
<div class="ltp-track"><div class="ltp-fill" id="ltpFill"></div></div>
<div class="ltp-demo-controls">
<label for="ltpSlider">Simulate points earned</label>
<input type="range" id="ltpSlider" min="0" max="12000" value="2340" step="10">
</div>
<div class="ltp-tiers" id="ltpTiers"></div>
</div>
</section>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:radial-gradient(120% 90% at 50% 0%,#1a1424,#06040a 60%);color:#fff;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:26px}
.ltp-wrap{width:100%;max-width:420px}
.ltp-card{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.1);border-radius:20px;padding:22px}
.ltp-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
.ltp-eyebrow{display:block;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#9d8fc7;margin-bottom:3px}
.ltp-head h2{font-size:24px;font-weight:800;letter-spacing:-.02em}
.ltp-badge{width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg,#a78bfa,#7c3aed);display:flex;align-items:center;justify-content:center;font-size:22px}
.ltp-points-row{display:flex;align-items:baseline;gap:7px;margin-bottom:18px}
.ltp-points-row>span:first-child{font-size:28px;font-weight:800;font-variant-numeric:tabular-nums}
.ltp-points-label{font-size:12.5px;color:#a99fc9;font-weight:600}
.ltp-progress-head{display:flex;justify-content:space-between;font-size:12px;font-weight:700;color:#c7bfe0;margin-bottom:7px}
.ltp-track{height:9px;border-radius:99px;background:rgba(255,255,255,.08);overflow:hidden;margin-bottom:18px}
.ltp-fill{height:100%;border-radius:99px;background:linear-gradient(90deg,#a78bfa,#f472b6);transition:width .4s cubic-bezier(.34,1.2,.4,1)}
.ltp-demo-controls{margin-bottom:20px;padding:12px;border-radius:10px;background:rgba(255,255,255,.03);border:1px dashed rgba(255,255,255,.13)}
.ltp-demo-controls label{display:block;font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:#8b7fac;margin-bottom:8px}
.ltp-demo-controls input{width:100%;accent-color:#a78bfa}
.ltp-tiers{display:flex;flex-direction:column;gap:8px}
.ltp-tier-row{display:flex;align-items:center;gap:11px;padding:10px 12px;border-radius:11px;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.02);transition:border-color .2s,background .2s}
.ltp-tier-row.active{border-color:rgba(167,139,250,.5);background:rgba(167,139,250,.1)}
.ltp-tier-icon{width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0;background:rgba(255,255,255,.06)}
.ltp-tier-info{flex:1;min-width:0}
.ltp-tier-info strong{display:block;font-size:12.5px;font-weight:800}
.ltp-tier-info span{display:block;font-size:11px;color:#9a92b8;margin-top:1px}
.ltp-tier-range{font-size:10.5px;font-weight:700;color:#8b7fac;font-variant-numeric:tabular-nums;text-align:right;flex-shrink:0}
.ltp-tier-row.active .ltp-tier-range{color:#c7bfe0}var slider = document.getElementById('ltpSlider');
var tierNameEl = document.getElementById('ltpTierName');
var badgeEl = document.getElementById('ltpBadge');
var pointsEarnedEl = document.getElementById('ltpPointsEarned');
var nextLabelEl = document.getElementById('ltpNextLabel');
var pctEl = document.getElementById('ltpPct');
var fillEl = document.getElementById('ltpFill');
var tiersEl = document.getElementById('ltpTiers');
var TIERS = [
{ name: 'Bronze', min: 0, icon: '🥉', benefit: 'Free standard shipping' },
{ name: 'Silver', min: 1500, icon: '🥈', benefit: '2x points on every order' },
{ name: 'Gold', min: 3500, icon: '🥇', benefit: 'Early access + free returns' },
{ name: 'Platinum', min: 7000, icon: '💎', benefit: 'Dedicated concierge support' },
{ name: 'Diamond', min: 12000, icon: '👑', benefit: 'Annual travel credit + all perks' }
];
function tierIndexFor(points) {
var idx = 0;
for (var i = 0; i < TIERS.length; i++) {
if (points >= TIERS[i].min) idx = i;
}
return idx;
}
function formatNum(n) { return n.toLocaleString(); }
function render(points) {
var idx = tierIndexFor(points);
var current = TIERS[idx];
var next = TIERS[idx + 1];
tierNameEl.textContent = current.name;
badgeEl.textContent = current.icon;
pointsEarnedEl.textContent = formatNum(points);
if (next) {
var span = next.min - current.min;
var into = points - current.min;
var pct = Math.min(100, Math.max(0, (into / span) * 100));
fillEl.style.width = pct + '%';
pctEl.textContent = Math.round(pct) + '%';
nextLabelEl.textContent = formatNum(next.min - points) + ' pts to ' + next.name;
} else {
fillEl.style.width = '100%';
pctEl.textContent = '100%';
nextLabelEl.textContent = 'Top tier reached';
}
renderTierList(idx);
}
function renderTierList(activeIdx) {
tiersEl.innerHTML = '';
TIERS.forEach(function (tier, i) {
var row = document.createElement('div');
row.className = 'ltp-tier-row' + (i === activeIdx ? ' active' : '');
var rangeText = TIERS[i + 1] ? (formatNum(tier.min) + '–' + formatNum(TIERS[i + 1].min - 1)) : (formatNum(tier.min) + '+');
row.innerHTML =
'<span class="ltp-tier-icon">' + tier.icon + '</span>' +
'<span class="ltp-tier-info"><strong>' + tier.name + '</strong><span>' + tier.benefit + '</span></span>' +
'<span class="ltp-tier-range">' + rangeText + '</span>';
tiersEl.appendChild(row);
});
}
slider.addEventListener('input', function () { render(+slider.value); });
render(+slider.value);Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Loyalty Program Tier Progress — Beyond a Single Points Bar

A points balance and a progress bar tell you how close you are to *something*, but not what that something actually unlocks. This widget is built around the full tier ladder: five named tiers, each with a real point range and a distinct benefit, with the current tier highlighted in context so the progress bar means something concrete.
A tier lookup, not a raw percentage
TIERS is an ordered array of { name, min, icon, benefit } objects. tierIndexFor(points) walks the list and returns the highest tier whose min threshold the current points have crossed — the same logic a real loyalty backend would use to assign a member's tier. Everything else in the widget derives from that one lookup.
Progress scoped to the current tier's span, not the whole scale
The progress bar doesn't measure points against some fixed maximum — it measures progress *within the current tier's range*: (points - current.min) / (next.min - current.min). That's why the bar resets to a fresh 0-100% at each tier boundary instead of slowly creeping across a single giant scale, and why the "points to next tier" label always shows the exact, correct gap regardless of which tier you're in.
The full ladder, always visible
Below the progress bar, every tier renders as its own row — icon, name, real benefit text, and its point range — with the current tier's row visually highlighted. This is the core difference from a simple points bar: a member can see not just how far they are from the next tier, but exactly what every tier above and below unlocks, which is what actually motivates progress. Drag the demo slider to watch the highlighted row, progress bar, and "points to next tier" label all update together at every tier boundary. Pair this with a quota usage meter for a broader account-status dashboard.
Step by step
How to Use
- 1Paste HTML, CSS, and JSRenders at 2,340 points — Silver tier, 67% to Gold.
- 2Drag the demo sliderPoints update live from 0 to 12,000.
- 3Cross a tier boundaryThe badge, name, and highlighted row all switch instantly.
- 4Reach the top tierThe progress bar fills and shows "Top tier reached."
- 5Read the tier listEvery tier shows its real point range and unlocked benefit.
- 6Wire up real dataCall render(points) with a member's actual point balance.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
A single points bar only shows how far a value is toward one fixed target. This widget is built around a full ordered tier ladder (five tiers, each with a real point range and a distinct benefit) — the current tier is looked up from that ladder, the progress bar is scoped to just the span between the current and next tier, and every tier's benefits are always visible in a list with the active one highlighted, so the member can see the whole path, not just the next checkpoint.
Rather than measuring points against a single overall maximum, the bar measures (points - currentTier.min) / (nextTier.min - currentTier.min). That means the bar resets to a fresh 0% right after crossing into a new tier and reaches 100% exactly at the next tier's threshold, so the visual progress always reflects standing within the current tier rather than an oddly compressed fraction of the entire five-tier range.
tierIndexFor() returns the highest tier whose minimum the points have crossed, so once points reach the top tier's threshold, there is no "next" tier to measure against. The code detects this (next is undefined) and fills the bar to 100% with a "Top tier reached" label instead of trying to divide by a nonexistent next threshold.
Yes — the slider just calls the same render(points) function that does everything: finding the current tier, computing progress within it, and re-rendering the highlighted tier list. Call render(memberPointsFromYourBackend) whenever the member's balance loads or changes, and remove the slider if manual demoing isn't needed.
Keep the raw points value in state, derive the current tier index and the next tier's threshold with the same lookup logic, and bind the progress bar's width, the points-to-next label, and the tier list's "active" class to those derived values — the TIERS array itself is static configuration and doesn't need to live in state.