LMS — Streak / XP Badges
A friendly e-learning gamification panel bundling four widgets: a daily-streak card with an animated flame and a seven-dot weekly calendar, an XP and level-progress bar with a hexagon rank badge, a daily-goal progress ring, and an eight-tile achievement grid of locked and unlocked badges. Interactions are vanilla JS — check in to grow the streak, claim a badge with a confetti burst and pop animation, earn XP that can trigger a level-up, and toggle a calm dark study mode.
MCP
Code
:root {
--brand: #5b5bd6;
--brand-d: #4444c2;
--brand-50: #eeeefc;
--accent: #13b981;
--amber: #f59e0b;
--ink: #1a1a2e;
--ink-2: #44465f;
--muted: #6b6e87;
--bg: #f7f7fb;
--surface: #ffffff;
--line: rgba(26, 26, 46, 0.1);
--ok: #13b981;
--danger: #e05656;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-sm: 0 1px 2px rgba(26, 26, 46, 0.06), 0 1px 3px rgba(26, 26, 46, 0.04);
--sh-md: 0 6px 18px rgba(26, 26, 46, 0.08);
--sh-lg: 0 18px 48px rgba(68, 68, 194, 0.16);
}
* {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(1100px 520px at 100% -10%, var(--brand-50), transparent 60%),
var(--bg);
min-height: 100vh;
transition: background 0.4s ease, color 0.4s ease;
}
.skip {
position: absolute;
left: -999px;
top: 0;
background: var(--brand);
color: #fff;
padding: 10px 14px;
border-radius: var(--r-sm);
z-index: 50;
}
.skip:focus {
left: 12px;
top: 12px;
}
.shell {
max-width: 1080px;
margin: 0 auto;
padding: 22px 20px 56px;
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 800;
font-size: 1.1rem;
letter-spacing: -0.02em;
}
.brand-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 11px;
background: linear-gradient(140deg, var(--brand), var(--brand-d));
color: #fff;
font-size: 1.1rem;
box-shadow: var(--sh-md);
}
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.study-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink-2);
font: inherit;
font-weight: 600;
font-size: 0.85rem;
padding: 8px 14px;
border-radius: 999px;
cursor: pointer;
box-shadow: var(--sh-sm);
transition: transform 0.12s ease, border-color 0.2s ease, background 0.2s;
}
.study-toggle:hover {
border-color: var(--brand);
}
.study-toggle:active {
transform: scale(0.97);
}
.study-toggle[aria-pressed="true"] {
background: var(--brand);
color: #fff;
border-color: var(--brand);
}
.study-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--amber);
}
.study-toggle[aria-pressed="true"] .study-dot {
background: #fff;
}
.avatar {
width: 38px;
height: 38px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 700;
font-size: 0.82rem;
color: #fff;
background: linear-gradient(140deg, #f0708e, #c2447a);
box-shadow: var(--sh-sm);
}
/* ---------- Grid ---------- */
.grid {
display: grid;
grid-template-columns: 1.05fr 1.05fr;
gap: 18px;
}
.badge-card {
grid-column: 1 / -1;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 22px;
box-shadow: var(--sh-md);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.card-head h2 {
margin: 0;
font-size: 1.02rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.pill {
font-size: 0.74rem;
font-weight: 700;
padding: 5px 11px;
border-radius: 999px;
white-space: nowrap;
}
.pill-amber {
color: #92560a;
background: #fdf0d8;
}
.pill-brand {
color: var(--brand-d);
background: var(--brand-50);
}
.pill-muted {
color: var(--ink-2);
background: #eef0f6;
}
/* ---------- Streak card ---------- */
.streak-hero {
display: flex;
align-items: center;
gap: 18px;
margin-bottom: 6px;
}
.flame {
filter: drop-shadow(0 6px 12px rgba(245, 158, 11, 0.35));
transition: transform 0.3s ease;
}
.flame-outer {
fill: url(#none);
fill: #f59e0b;
}
.flame-inner {
fill: #fde047;
}
.flame svg {
display: block;
}
.flame.pulse {
animation: flamePulse 0.7s ease;
}
@keyframes flamePulse {
0% { transform: scale(1); }
35% { transform: scale(1.22) rotate(-3deg); }
70% { transform: scale(0.96) rotate(2deg); }
100% { transform: scale(1); }
}
.streak-num {
display: flex;
flex-direction: column;
line-height: 1;
}
.streak-count {
font-size: 3.4rem;
font-weight: 800;
letter-spacing: -0.04em;
color: var(--ink);
}
.streak-label {
font-size: 0.92rem;
font-weight: 600;
color: var(--muted);
margin-top: 4px;
}
.streak-sub {
margin: 0 0 16px;
font-size: 0.88rem;
color: var(--ink-2);
}
.week {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
margin-bottom: 18px;
}
.dot {
aspect-ratio: 1;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.74rem;
font-weight: 700;
border: 2px solid var(--line);
color: var(--muted);
background: #fafafe;
transition: transform 0.18s ease, background 0.25s ease, border-color 0.25s;
}
.dot.done {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.dot.today {
border-color: var(--brand);
color: var(--brand-d);
background: var(--brand-50);
box-shadow: 0 0 0 3px rgba(91, 91, 214, 0.14);
}
.dot.today.done {
background: var(--accent);
border-color: var(--accent);
color: #fff;
box-shadow: 0 0 0 3px rgba(19, 185, 129, 0.2);
}
.dot.pop {
animation: dotPop 0.4s ease;
}
@keyframes dotPop {
0% { transform: scale(0.6); }
60% { transform: scale(1.18); }
100% { transform: scale(1); }
}
.btn {
font: inherit;
font-weight: 700;
border: none;
border-radius: var(--r-md);
cursor: pointer;
width: 100%;
padding: 13px 16px;
transition: transform 0.12s ease, box-shadow 0.2s ease, opacity 0.2s;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background: linear-gradient(140deg, var(--brand), var(--brand-d));
color: #fff;
box-shadow: var(--sh-lg);
}
.btn-primary:hover {
box-shadow: 0 22px 56px rgba(68, 68, 194, 0.26);
}
.btn-primary:disabled,
.btn.is-done {
background: #e9ecf4;
color: var(--muted);
box-shadow: none;
cursor: default;
}
.freeze-note {
margin: 12px 0 0;
text-align: center;
font-size: 0.8rem;
color: var(--muted);
}
/* ---------- XP card ---------- */
.xp-top {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 14px;
}
.rank-badge {
position: relative;
width: 52px;
height: 58px;
flex: none;
display: grid;
place-items: center;
color: #fff;
font-weight: 800;
font-size: 1.3rem;
background: linear-gradient(140deg, var(--brand), var(--brand-d));
clip-path: polygon(50% 0, 100% 26%, 100% 74%, 50% 100%, 0 74%, 0 26%);
box-shadow: var(--sh-md);
}
.xp-title {
margin: 0;
font-weight: 700;
font-size: 1rem;
}
.xp-detail {
margin: 2px 0 0;
font-size: 0.85rem;
color: var(--muted);
}
.xp-detail strong {
color: var(--ink);
}
.xp-bar {
height: 14px;
border-radius: 999px;
background: #eef0f6;
overflow: hidden;
border: 1px solid var(--line);
}
.xp-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--brand), #8b8bf0);
transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
}
.xp-fill::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.45), transparent);
transform: translateX(-100%);
animation: shimmer 2.4s ease-in-out infinite;
}
@keyframes shimmer {
50%, 100% { transform: translateX(100%); }
}
.xp-bar-foot {
display: flex;
justify-content: space-between;
margin-top: 7px;
font-size: 0.74rem;
font-weight: 600;
color: var(--muted);
}
.goal-row {
display: flex;
align-items: center;
gap: 16px;
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid var(--line);
}
.ring {
position: relative;
flex: none;
width: 80px;
height: 80px;
display: grid;
place-items: center;
}
.ring svg {
transform: rotate(-90deg);
}
.ring-track {
fill: none;
stroke: #eef0f6;
stroke-width: 9;
}
.ring-arc {
fill: none;
stroke: var(--accent);
stroke-width: 9;
stroke-linecap: round;
stroke-dasharray: 213.6;
stroke-dashoffset: 42.7;
transition: stroke-dashoffset 0.9s cubic-bezier(0.22, 1, 0.36, 1);
}
.ring-label {
position: absolute;
font-weight: 800;
font-size: 1rem;
color: var(--ink);
}
.goal-h {
margin: 0;
font-weight: 700;
font-size: 0.95rem;
}
.goal-d {
margin: 3px 0 0;
font-size: 0.85rem;
color: var(--muted);
}
.goal-d strong {
color: var(--accent);
}
.goal-tip {
margin: 5px 0 0;
font-size: 0.78rem;
color: var(--muted);
}
/* ---------- Badges ---------- */
.badge-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.badge {
position: relative;
text-align: center;
padding: 18px 10px 14px;
border-radius: var(--r-md);
border: 1px solid var(--line);
background: #fafafe;
cursor: default;
transition: transform 0.16s ease, box-shadow 0.2s ease, border-color 0.2s;
}
.badge.unlocked {
background: var(--surface);
border-color: rgba(91, 91, 214, 0.25);
}
.badge.unlocked:hover {
transform: translateY(-3px);
box-shadow: var(--sh-md);
}
.badge.locked {
cursor: pointer;
}
.badge.locked:hover {
border-color: var(--brand);
}
.badge.claimable::after {
content: "Claim";
position: absolute;
top: 8px;
right: 8px;
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #fff;
background: var(--amber);
padding: 3px 7px;
border-radius: 999px;
animation: nudge 1.6s ease-in-out infinite;
}
@keyframes nudge {
50% { transform: translateY(-2px); }
}
.badge-ico {
width: 56px;
height: 56px;
margin: 0 auto 10px;
display: grid;
place-items: center;
font-size: 1.7rem;
border-radius: 50%;
background: var(--brand-50);
transition: filter 0.3s ease, transform 0.3s ease;
}
.badge.locked .badge-ico {
filter: grayscale(1);
opacity: 0.45;
background: #eef0f6;
}
.badge-name {
display: block;
font-weight: 700;
font-size: 0.8rem;
color: var(--ink);
}
.badge.locked .badge-name {
color: var(--muted);
}
.badge-meta {
display: block;
font-size: 0.68rem;
color: var(--muted);
margin-top: 3px;
}
.badge.just-unlocked {
animation: claimPop 0.65s cubic-bezier(0.22, 1, 0.36, 1);
}
.badge.just-unlocked .badge-ico {
animation: icoSpin 0.7s ease;
}
@keyframes claimPop {
0% { transform: scale(0.9); }
45% { transform: scale(1.07); box-shadow: 0 0 0 6px rgba(245, 158, 11, 0.18); }
100% { transform: scale(1); }
}
@keyframes icoSpin {
0% { transform: rotate(-25deg) scale(0.7); }
60% { transform: rotate(8deg) scale(1.18); }
100% { transform: rotate(0) scale(1); }
}
/* ---------- Toast ---------- */
.toast-wrap {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
z-index: 60;
width: min(92vw, 420px);
}
.toast {
display: flex;
align-items: center;
gap: 10px;
background: var(--ink);
color: #fff;
padding: 12px 16px;
border-radius: var(--r-md);
font-size: 0.88rem;
font-weight: 600;
box-shadow: var(--sh-lg);
animation: toastIn 0.3s ease;
}
.toast.ok { background: #0f9a6e; }
.toast.xp { background: var(--brand-d); }
.toast .t-ico { font-size: 1.1rem; }
.toast.out { animation: toastOut 0.3s ease forwards; }
@keyframes toastIn {
from { transform: translateY(16px); opacity: 0; }
}
@keyframes toastOut {
to { transform: translateY(16px); opacity: 0; }
}
/* ---------- Confetti ---------- */
.confetti {
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 55;
}
.confetti i {
position: absolute;
top: -16px;
width: 9px;
height: 14px;
border-radius: 2px;
animation: fall linear forwards;
}
@keyframes fall {
to { transform: translateY(105vh) rotate(540deg); opacity: 0; }
}
/* ---------- Study mode ---------- */
body.study {
--bg: #14141f;
--surface: #1d1d2b;
--ink: #f2f2f8;
--ink-2: #c5c6d8;
--muted: #9092ad;
--line: rgba(255, 255, 255, 0.1);
--brand-50: #262648;
background:
radial-gradient(1000px 480px at 100% -10%, #20204a, transparent 60%),
#14141f;
}
body.study .dot {
background: #232333;
}
body.study .xp-bar,
body.study .ring-track,
body.study .pill-muted {
background: #2a2a3c;
}
body.study .badge,
body.study .badge.locked .badge-ico {
background: #232333;
}
body.study .btn.is-done {
background: #2a2a3c;
}
/* ---------- Responsive ---------- */
@media (max-width: 820px) {
.grid {
grid-template-columns: 1fr;
}
.badge-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 520px) {
.shell {
padding: 16px 14px 48px;
}
.card {
padding: 18px;
}
.streak-count {
font-size: 2.9rem;
}
.badge-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.week {
gap: 6px;
}
.brand-name {
font-size: 1rem;
}
}(function () {
"use strict";
/* ---------- State ---------- */
var state = {
streak: 7,
best: 21,
checkedIn: false,
xp: 1840,
levelFloor: 1500,
levelCeil: 2000,
level: 6,
goalDone: 40,
goalTarget: 50,
};
var ringCircumference = 2 * Math.PI * 34; // r = 34
var badges = [
{ id: "first-lesson", icon: "🎓", name: "First Lesson", meta: "Unlocked Apr 3", state: "unlocked" },
{ id: "night-owl", icon: "🦉", name: "Night Owl", meta: "Study after 10pm", state: "unlocked" },
{ id: "quiz-ace", icon: "🎯", name: "Quiz Ace", meta: "100% on a quiz", state: "unlocked" },
{ id: "week-warrior", icon: "🔥", name: "Week Warrior", meta: "7-day streak", state: "unlocked" },
{ id: "marathon", icon: "🏅", name: "Marathon", meta: "Claim your badge", state: "claimable" },
{ id: "polyglot", icon: "🌍", name: "Polyglot", meta: "3 courses done", state: "locked" },
{ id: "mentor", icon: "🤝", name: "Mentor", meta: "Help 5 peers", state: "locked" },
{ id: "perfectionist", icon: "💎", name: "Perfect Month", meta: "30-day streak", state: "locked" },
];
/* ---------- Helpers ---------- */
var $ = function (sel) { return document.querySelector(sel); };
function toast(msg, kind, icon) {
var wrap = $("#toastWrap");
var el = document.createElement("div");
el.className = "toast" + (kind ? " " + kind : "");
el.innerHTML = '<span class="t-ico" aria-hidden="true">' + (icon || "✅") + "</span><span>" + msg + "</span>";
wrap.appendChild(el);
setTimeout(function () {
el.classList.add("out");
setTimeout(function () { el.remove(); }, 320);
}, 2600);
}
function fmt(n) { return n.toLocaleString("en-US"); }
function burstConfetti() {
var layer = $("#confetti");
var colors = ["#5b5bd6", "#13b981", "#f59e0b", "#f0708e", "#8b8bf0"];
for (var i = 0; i < 60; i++) {
var p = document.createElement("i");
p.style.left = Math.random() * 100 + "vw";
p.style.background = colors[i % colors.length];
p.style.animationDuration = (1.4 + Math.random() * 1.4).toFixed(2) + "s";
p.style.animationDelay = (Math.random() * 0.3).toFixed(2) + "s";
p.style.width = (6 + Math.random() * 6).toFixed(0) + "px";
layer.appendChild(p);
(function (node) {
setTimeout(function () { node.remove(); }, 3000);
})(p);
}
}
/* ---------- XP bar ---------- */
function renderXp() {
var span = state.levelCeil - state.levelFloor;
var into = state.xp - state.levelFloor;
var pct = Math.max(0, Math.min(100, (into / span) * 100));
var fill = $("#xpFill");
fill.style.width = pct + "%";
fill.parentElement.setAttribute("aria-valuenow", String(state.xp));
$("#xpCurrent").textContent = fmt(state.xp);
$("#xpToGo").textContent = fmt(Math.max(0, state.levelCeil - state.xp));
$("#levelPill").textContent = "Level " + state.level;
}
function awardXp(amount, reason) {
state.xp += amount;
// level up
while (state.xp >= state.levelCeil) {
state.level += 1;
state.levelFloor = state.levelCeil;
state.levelCeil += 500;
$("#rankNum").textContent = String(state.level);
var titles = { 6: "Scholar", 7: "Mentor", 8: "Master", 9: "Sage" };
$("#rankTitle").textContent = titles[state.level] || "Legend";
toast("Level up! You're now Level " + state.level, "xp", "⬆️");
burstConfetti();
}
renderXp();
if (reason) toast("+" + amount + " XP · " + reason, "xp", "✨");
}
/* ---------- Daily goal ring ---------- */
function renderRing() {
var pct = Math.min(100, Math.round((state.goalDone / state.goalTarget) * 100));
var arc = $("#ringArc");
arc.style.strokeDashoffset = String(ringCircumference * (1 - pct / 100));
$("#ringLabel").textContent = pct + "%";
$("#goalRing").setAttribute("aria-label", "Daily goal " + pct + " percent complete");
$("#goalDone").textContent = String(state.goalDone);
}
/* ---------- Streak check-in ---------- */
function checkIn() {
if (state.checkedIn) return;
state.checkedIn = true;
state.streak += 1;
var countEl = $("#streakCount");
countEl.textContent = String(state.streak);
var flame = $("#flame");
flame.classList.remove("pulse");
void flame.offsetWidth;
flame.classList.add("pulse");
var today = $("#todayDot");
today.classList.add("done", "pop");
setTimeout(function () { today.classList.remove("pop"); }, 420);
var btn = $("#checkInBtn");
btn.textContent = "Checked in ✓";
btn.classList.add("is-done");
btn.disabled = true;
var sub = $("#streakSub");
if (state.streak > state.best) {
state.best = state.streak;
$("#streakBest").textContent = "Best · " + state.best + " days";
sub.textContent = "New personal best — " + state.streak + " days in a row!";
burstConfetti();
} else {
sub.textContent = state.best - state.streak + " more day(s) to beat your best of " + state.best + ".";
}
// goal + xp rewards
state.goalDone = Math.min(state.goalTarget, state.goalDone + 10);
renderRing();
toast(state.streak + "-day streak secured!", "ok", "🔥");
awardXp(20, "daily check-in");
}
/* ---------- Badges ---------- */
function renderBadges() {
var grid = $("#badgeGrid");
grid.innerHTML = "";
var unlocked = 0;
badges.forEach(function (b) {
if (b.state === "unlocked") unlocked++;
var el = document.createElement(b.state === "claimable" ? "button" : "div");
el.className = "badge " + (b.state === "unlocked" ? "unlocked" : b.state === "claimable" ? "locked claimable" : "locked");
el.dataset.id = b.id;
if (b.state === "claimable") el.type = "button";
el.innerHTML =
'<span class="badge-ico" aria-hidden="true">' + b.icon + "</span>" +
'<span class="badge-name">' + b.name + "</span>" +
'<span class="badge-meta">' + b.meta + "</span>";
if (b.state === "claimable") {
el.setAttribute("aria-label", "Claim badge: " + b.name);
el.addEventListener("click", function () { claimBadge(b.id, el); });
}
grid.appendChild(el);
});
$("#unlockedCount").textContent = String(unlocked);
}
function claimBadge(id, el) {
var b = badges.find(function (x) { return x.id === id; });
if (!b || b.state === "unlocked") return;
b.state = "unlocked";
b.meta = "Just earned!";
el.className = "badge unlocked just-unlocked";
el.replaceWith(el.cloneNode(true)); // strip click listener
renderBadges();
burstConfetti();
toast("Badge unlocked: " + b.name, "ok", b.icon);
awardXp(50, b.name + " badge");
}
/* ---------- Study mode ---------- */
function bindStudyToggle() {
var btn = $("#studyToggle");
btn.addEventListener("click", function () {
var on = document.body.classList.toggle("study");
btn.setAttribute("aria-pressed", String(on));
toast(on ? "Study mode on — easy on the eyes" : "Study mode off", "", on ? "🌙" : "☀️");
});
}
/* ---------- Init ---------- */
function init() {
renderXp();
renderRing();
renderBadges();
bindStudyToggle();
$("#checkInBtn").addEventListener("click", checkIn);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LMS — Streak / XP Badges</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<div class="shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">◐</span>
<span class="brand-name">LearnLoop</span>
</div>
<div class="topbar-right">
<button class="study-toggle" id="studyToggle" type="button" aria-pressed="false">
<span class="study-dot" aria-hidden="true"></span>
Study mode
</button>
<div class="avatar" title="Mara Okonkwo" aria-label="Mara Okonkwo">MO</div>
</div>
</header>
<main class="grid" id="main">
<!-- STREAK CARD -->
<section class="card streak-card" aria-labelledby="streak-h">
<div class="card-head">
<h2 id="streak-h">Daily streak</h2>
<span class="pill pill-amber" id="streakBest">Best · 21 days</span>
</div>
<div class="streak-hero">
<div class="flame" id="flame" aria-hidden="true">
<svg viewBox="0 0 48 64" width="64" height="84">
<path class="flame-outer" d="M24 2c4 10 16 14 16 30 0 13-8 24-16 24S8 45 8 32C8 20 17 16 19 6c3 5 1 11 5 14 2-6 1-11-0-18Z"/>
<path class="flame-inner" d="M24 22c3 6 8 9 8 18 0 8-4 14-8 14s-9-6-9-14c0-6 4-9 6-13 1 3 1 6 3 7 1-4 0-8 0-12Z"/>
</svg>
</div>
<div class="streak-num">
<span class="streak-count" id="streakCount">7</span>
<span class="streak-label">day streak</span>
</div>
</div>
<p class="streak-sub" id="streakSub">You're on fire — 1 more day to beat your best!</p>
<div class="week" role="group" aria-label="This week's activity">
<div class="dot done" data-day="M"><span>M</span></div>
<div class="dot done" data-day="T"><span>T</span></div>
<div class="dot done" data-day="W"><span>W</span></div>
<div class="dot done" data-day="T"><span>T</span></div>
<div class="dot done" data-day="F"><span>F</span></div>
<div class="dot done" data-day="S"><span>S</span></div>
<div class="dot today" id="todayDot" data-day="S"><span>S</span></div>
</div>
<button class="btn btn-primary" id="checkInBtn" type="button">
Check in for today
</button>
<p class="freeze-note">🛡️ 2 streak freezes available</p>
</section>
<!-- XP / LEVEL CARD -->
<section class="card xp-card" aria-labelledby="xp-h">
<div class="card-head">
<h2 id="xp-h">Level progress</h2>
<span class="pill pill-brand" id="levelPill">Level 6</span>
</div>
<div class="xp-top">
<div class="rank-badge" aria-hidden="true">
<span class="rank-num" id="rankNum">6</span>
</div>
<div class="xp-meta">
<p class="xp-title" id="rankTitle">Scholar</p>
<p class="xp-detail"><strong id="xpCurrent">1,840</strong> XP · <span id="xpToGo">160</span> to Level 7</p>
</div>
</div>
<div class="xp-bar" role="progressbar" aria-valuemin="0" aria-valuemax="2000" aria-valuenow="1840" aria-label="XP toward next level">
<div class="xp-fill" id="xpFill" style="width:92%"></div>
</div>
<div class="xp-bar-foot">
<span id="xpFloor">Lv 6 · 1,500</span>
<span id="xpCeil">Lv 7 · 2,000</span>
</div>
<div class="goal-row">
<div class="ring" id="goalRing" role="img" aria-label="Daily goal 80 percent complete">
<svg viewBox="0 0 80 80" width="80" height="80">
<circle class="ring-track" cx="40" cy="40" r="34" />
<circle class="ring-arc" id="ringArc" cx="40" cy="40" r="34" />
</svg>
<span class="ring-label" id="ringLabel">80%</span>
</div>
<div class="goal-text">
<p class="goal-h">Daily goal</p>
<p class="goal-d"><strong id="goalDone">40</strong> / 50 XP earned today</p>
<p class="goal-tip">Finish one lesson to complete your ring.</p>
</div>
</div>
</section>
<!-- BADGE GRID -->
<section class="card badge-card" aria-labelledby="badge-h">
<div class="card-head">
<h2 id="badge-h">Achievements</h2>
<span class="pill pill-muted"><span id="unlockedCount">4</span> / 8 unlocked</span>
</div>
<div class="badge-grid" id="badgeGrid">
<!-- badges injected by script -->
</div>
</section>
</main>
</div>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
<!-- confetti layer -->
<div class="confetti" id="confetti" aria-hidden="true"></div>
<script src="script.js"></script>
</body>
</html>Streak / XP Badges
A compact gamification dashboard for a learning platform, built from four reusable widgets. The streak card pairs a big day count with an animated SVG flame and a row of seven calendar dots showing this week’s activity — six green check-offs and a highlighted “today” ring. Beside it, the level card shows a hexagon rank badge, a shimmering XP progress bar with floor and ceiling labels, and a circular daily-goal ring that fills toward 50 XP. Below, an achievement grid lays out eight badges in friendly unlocked, claimable, and locked states.
Everything is interactive with plain vanilla JS. “Check in for today” increments the streak, pulses the flame, pops the today dot green, awards XP, advances the goal ring, and celebrates a new personal best with confetti. Claimable badges respond to a click with a spin-and-pop unlock animation, a confetti burst, and a bonus XP reward; banking enough XP rolls the level up and bumps the rank title. A small toast() helper drives every confirmation, and a study-mode toggle swaps the whole panel into a calm dark theme.
The layout uses the LMS design tokens — indigo brand, emerald progress, amber accents, soft shadows, and pill-shaped difficulty and level badges — and is responsive from desktop down to roughly 360px, where the badge grid reflows to two columns. Semantic landmarks, ARIA progressbar and image roles, a skip link, and keyboard-usable controls keep it accessible.
Illustrative UI only — fictional courses, not a real learning platform.