UI Components Easy
Leaderboard
A ranked leaderboard component with score bars, animated rank changes, medal icons for top 3, avatar initials, and a live-update simulation mode. Ideal for gamification and analytics dashboards.
Open in Lab
MCP
vanilla-js css react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--surface: #16181f;
--surface2: #1e2130;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
--gold: #f59e0b;
--silver: #94a3b8;
--bronze: #b45309;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 40px 24px;
}
.page {
max-width: 600px;
margin: 0 auto;
}
.lb-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.page-title {
font-size: 1.3rem;
font-weight: 700;
}
.ctrl-btn {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 600;
padding: 6px 14px;
border-radius: 7px;
cursor: pointer;
font-family: inherit;
transition: all .15s;
}
.ctrl-btn.active,
.ctrl-btn:hover {
border-color: #818cf8;
color: #818cf8;
}
.leaderboard {
display: flex;
flex-direction: column;
gap: 8px;
}
.lb-entry {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
transition: transform .3s ease, border-color .2s;
}
.lb-entry:hover {
border-color: #3a3d4e;
}
.lb-rank {
font-size: 1rem;
font-weight: 800;
width: 28px;
text-align: center;
flex-shrink: 0;
}
.lb-rank.gold {
color: var(--gold);
}
.lb-rank.silver {
color: var(--silver);
}
.lb-rank.bronze {
color: var(--bronze);
}
.lb-av {
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.7rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.lb-info {
flex: 1;
min-width: 0;
}
.lb-name {
font-size: 0.875rem;
font-weight: 700;
}
.lb-score-bar-wrap {
height: 4px;
background: var(--surface2);
border-radius: 2px;
margin-top: 5px;
overflow: hidden;
}
.lb-score-bar {
height: 100%;
border-radius: 2px;
transition: width .4s ease;
}
.lb-score {
font-weight: 800;
font-size: 0.9rem;
flex-shrink: 0;
}
.lb-delta {
font-size: 0.7rem;
font-weight: 700;
width: 26px;
text-align: center;
flex-shrink: 0;
}
.lb-delta.up {
color: #34d399;
}
.lb-delta.down {
color: #f87171;
}
.lb-delta.same {
color: var(--text-muted);
}let USERS = [
{ id: 1, name: "Alice Smith", score: 9850, prevRank: 1 },
{ id: 2, name: "Bob Johnson", score: 8420, prevRank: 2 },
{ id: 3, name: "Carol Williams", score: 8100, prevRank: 3 },
{ id: 4, name: "David Brown", score: 7650, prevRank: 4 },
{ id: 5, name: "Eve Davis", score: 6980, prevRank: 5 },
{ id: 6, name: "Frank Miller", score: 6200, prevRank: 6 },
{ id: 7, name: "Grace Wilson", score: 5800, prevRank: 7 },
];
const COLORS = ["#818cf8", "#34d399", "#f59e0b", "#f87171", "#a78bfa", "#38bdf8", "#fb7185"];
const board = document.getElementById("leaderboard");
let liveInterval = null;
function getInitials(name) {
return name
.split(" ")
.map((n) => n[0])
.join("")
.substring(0, 2);
}
function render(animateBars = false) {
// Sort by score
USERS.sort((a, b) => b.score - a.score);
// Update ranks
USERS.forEach((u, i) => {
u.rank = i + 1;
});
const maxScore = USERS[0].score * 1.1;
board.innerHTML = "";
USERS.forEach((u, i) => {
const el = document.createElement("div");
el.className = "lb-entry";
// Position absolute for sorting animation if needed. We'll use order for flexbox or simple list replacement
// Simple list replacement works smoothly enough for score bars
let rankCls = "";
if (u.rank === 1) rankCls = "gold";
else if (u.rank === 2) rankCls = "silver";
else if (u.rank === 3) rankCls = "bronze";
const rankDiff = u.prevRank - u.rank;
let rankArrow = `<span class="lb-delta same">—</span>`;
if (rankDiff > 0) rankArrow = `<span class="lb-delta up">▲ ${rankDiff}</span>`;
else if (rankDiff < 0) rankArrow = `<span class="lb-delta down">▼ ${Math.abs(rankDiff)}</span>`;
const color = COLORS[u.id % COLORS.length];
const targetW = (u.score / maxScore) * 100 + "%";
const initW = animateBars ? "0%" : targetW;
el.innerHTML = `
<div class="lb-rank ${rankCls}">${u.rank}</div>
${rankArrow}
<div class="lb-av" style="background:${color}">${getInitials(u.name)}</div>
<div class="lb-info">
<div class="lb-name">${u.name}</div>
<div class="lb-score-bar-wrap">
<div class="lb-score-bar" style="background:${color}; width:${initW};" data-w="${targetW}"></div>
</div>
</div>
<div class="lb-score">${u.score.toLocaleString()}</div>
`;
board.appendChild(el);
if (animateBars) {
setTimeout(() => {
el.querySelector(".lb-score-bar").style.width = targetW;
}, 50);
}
});
}
render(true);
document.getElementById("liveBtn").addEventListener("click", (e) => {
if (liveInterval) {
clearInterval(liveInterval);
liveInterval = null;
e.target.textContent = "▶ Start Live";
e.target.classList.remove("active");
} else {
e.target.textContent = "■ Stop Live";
e.target.classList.add("active");
liveInterval = setInterval(() => {
// Simulate live score changes
USERS.forEach((u) => {
u.prevRank = u.rank;
u.score += Math.floor(Math.random() * 500); // add 0-500 points
});
render(true);
}, 2500);
}
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Leaderboard</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<div class="lb-header">
<h1 class="page-title">Leaderboard</h1>
<button class="ctrl-btn" id="liveBtn">▶ Start Live</button>
</div>
<div class="leaderboard" id="leaderboard"></div>
</main>
<script src="script.js"></script>
</body>
</html>import { useState, useEffect, useRef } from "react";
const INITIAL_USERS = [
{ id: 1, name: "Alice Smith", score: 9850, prevRank: 1 },
{ id: 2, name: "Bob Johnson", score: 8420, prevRank: 2 },
{ id: 3, name: "Carol Williams", score: 8100, prevRank: 3 },
{ id: 4, name: "David Brown", score: 7650, prevRank: 4 },
{ id: 5, name: "Eve Davis", score: 6980, prevRank: 5 },
{ id: 6, name: "Frank Miller", score: 6200, prevRank: 6 },
{ id: 7, name: "Grace Wilson", score: 5800, prevRank: 7 },
];
const COLORS = ["#818cf8", "#34d399", "#f59e0b", "#f87171", "#a78bfa", "#38bdf8", "#fb7185"];
function initials(name: string) {
return name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2);
}
function rankCls(rank: number) {
if (rank === 1) return "text-[#f59e0b]";
if (rank === 2) return "text-[#94a3b8]";
if (rank === 3) return "text-[#b45309]";
return "text-[#484f58]";
}
type User = (typeof INITIAL_USERS)[0] & { rank?: number };
function ScoreBar({
value,
max,
color,
animate,
}: { value: number; max: number; color: string; animate: boolean }) {
const ref = useRef<HTMLDivElement>(null);
const pct = ((value / max) * 100).toFixed(1) + "%";
useEffect(() => {
if (!ref.current) return;
ref.current.style.width = "0%";
const t = setTimeout(() => {
if (ref.current) ref.current.style.width = pct;
}, 50);
return () => clearTimeout(t);
}, [pct, animate]);
return (
<div className="flex-1 h-2 bg-[#21262d] rounded-full overflow-hidden">
<div
ref={ref}
className="h-full rounded-full transition-[width] duration-500 ease-out"
style={{ background: color }}
/>
</div>
);
}
export default function LeaderboardRC() {
const [users, setUsers] = useState<User[]>(INITIAL_USERS);
const [live, setLive] = useState(false);
const [tick, setTick] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const sorted = [...users]
.sort((a, b) => b.score - a.score)
.map((u, i) => ({ ...u, rank: i + 1 }));
const maxScore = sorted[0].score * 1.1;
useEffect(() => {
if (live) {
intervalRef.current = setInterval(() => {
setUsers((prev) =>
prev.map((u) => ({
...u,
prevRank: u.rank ?? u.id,
score: u.score + Math.floor(Math.random() * 500),
}))
);
setTick((t) => t + 1);
}, 2500);
} else {
if (intervalRef.current) clearInterval(intervalRef.current);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [live]);
return (
<div className="min-h-screen bg-[#0d1117] p-6 flex justify-center">
<div className="w-full max-w-[560px]">
<div className="flex items-center justify-between mb-4">
<h2 className="text-[#e6edf3] font-bold text-[16px]">Leaderboard</h2>
<button
onClick={() => setLive((l) => !l)}
className={`px-3 py-1.5 rounded-lg text-[12px] font-semibold border transition-colors ${live ? "bg-[#34d399]/20 border-[#34d399] text-[#34d399]" : "border-[#30363d] text-[#8b949e] hover:border-[#8b949e]"}`}
>
{live ? "■ Stop Live" : "▶ Start Live"}
</button>
</div>
<div className="flex flex-col gap-2">
{sorted.map((u) => {
const color = COLORS[u.id % COLORS.length];
const rankDiff = (u.prevRank || u.rank) - u.rank!;
return (
<div
key={u.id}
className="bg-[#161b22] border border-[#30363d] rounded-xl px-4 py-3 flex items-center gap-3 hover:border-[#8b949e]/40 transition-all"
>
<div className={`w-6 text-center font-bold text-[14px] ${rankCls(u.rank!)}`}>
{u.rank! <= 3 ? ["🥇", "🥈", "🥉"][u.rank! - 1] : u.rank}
</div>
<div className="text-[10px] w-8 text-center">
{rankDiff > 0 && <span className="text-[#34d399]">▲{rankDiff}</span>}
{rankDiff < 0 && <span className="text-[#f87171]">▼{Math.abs(rankDiff)}</span>}
{rankDiff === 0 && <span className="text-[#484f58]">—</span>}
</div>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-[11px] font-bold text-white flex-shrink-0"
style={{ background: color }}
>
{initials(u.name)}
</div>
<div className="flex-1 min-w-0">
<div className="text-[#e6edf3] text-[13px] font-medium truncate">{u.name}</div>
<div className="flex items-center gap-2 mt-1">
<ScoreBar value={u.score} max={maxScore} color={color} animate={tick > 0} />
</div>
</div>
<div className="text-[#e6edf3] font-bold text-[14px] tabular-nums">
{u.score.toLocaleString()}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}<script setup>
import { ref, computed, onUnmounted } from "vue";
const INITIAL_USERS = [
{ id: 1, name: "Alice Smith", score: 9850, prevRank: 1 },
{ id: 2, name: "Bob Johnson", score: 8420, prevRank: 2 },
{ id: 3, name: "Carol Williams", score: 8100, prevRank: 3 },
{ id: 4, name: "David Brown", score: 7650, prevRank: 4 },
{ id: 5, name: "Eve Davis", score: 6980, prevRank: 5 },
{ id: 6, name: "Frank Miller", score: 6200, prevRank: 6 },
{ id: 7, name: "Grace Wilson", score: 5800, prevRank: 7 },
];
const COLORS = ["#818cf8", "#34d399", "#f59e0b", "#f87171", "#a78bfa", "#38bdf8", "#fb7185"];
function initials(name) {
return name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2);
}
const users = ref(INITIAL_USERS.map((u) => ({ ...u })));
const live = ref(false);
const barWidths = ref({});
let intervalId = null;
const sorted = computed(() =>
[...users.value].sort((a, b) => b.score - a.score).map((u, i) => ({ ...u, rank: i + 1 }))
);
const maxScore = computed(() => sorted.value[0].score * 1.1);
function animateBars() {
sorted.value.forEach((u) => {
barWidths.value[u.id] = "0%";
});
setTimeout(() => {
sorted.value.forEach((u) => {
barWidths.value[u.id] = ((u.score / maxScore.value) * 100).toFixed(1) + "%";
});
}, 50);
}
function getBarWidth(u) {
return barWidths.value[u.id] || ((u.score / maxScore.value) * 100).toFixed(1) + "%";
}
function toggleLive() {
live.value = !live.value;
if (live.value) {
intervalId = setInterval(() => {
users.value = users.value.map((u) => ({
...u,
prevRank: sorted.value.find((s) => s.id === u.id)?.rank || u.prevRank,
score: u.score + Math.floor(Math.random() * 500),
}));
animateBars();
}, 2500);
} else {
clearInterval(intervalId);
intervalId = null;
}
}
function rankColor(rank) {
if (rank === 1) return "#f59e0b";
if (rank === 2) return "#94a3b8";
if (rank === 3) return "#b45309";
return "#484f58";
}
function rankIcon(rank) {
if (rank <= 3) return ["\u{1F947}", "\u{1F948}", "\u{1F949}"][rank - 1];
return String(rank);
}
function rankDiff(u) {
return (u.prevRank || u.rank) - u.rank;
}
onUnmounted(() => {
if (intervalId) clearInterval(intervalId);
});
</script>
<template>
<div style="min-height:100vh;background:#0d1117;padding:1.5rem;display:flex;justify-content:center;font-family:system-ui,-apple-system,sans-serif;color:#e6edf3">
<div style="width:100%;max-width:560px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem">
<h2 style="font-weight:700;font-size:16px;margin:0">Leaderboard</h2>
<button
@click="toggleLive"
:style="{
padding: '0.375rem 0.75rem',
borderRadius: '0.5rem',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
border: live ? '1px solid #34d399' : '1px solid #30363d',
background: live ? 'rgba(52,211,153,0.2)' : 'transparent',
color: live ? '#34d399' : '#8b949e',
}"
>{{ live ? '\u25A0 Stop Live' : '\u25B6 Start Live' }}</button>
</div>
<div style="display:flex;flex-direction:column;gap:0.5rem">
<div
v-for="u in sorted"
:key="u.id"
style="background:#161b22;border:1px solid #30363d;border-radius:0.75rem;padding:0.75rem 1rem;display:flex;align-items:center;gap:0.75rem"
>
<div :style="{ width: '1.5rem', textAlign: 'center', fontWeight: '700', fontSize: '14px', color: rankColor(u.rank) }">
{{ rankIcon(u.rank) }}
</div>
<div style="font-size:10px;width:2rem;text-align:center">
<span v-if="rankDiff(u) > 0" style="color:#34d399">\u25B2{{ rankDiff(u) }}</span>
<span v-else-if="rankDiff(u) < 0" style="color:#f87171">\u25BC{{ Math.abs(rankDiff(u)) }}</span>
<span v-else style="color:#484f58">\u2014</span>
</div>
<div
:style="{
width: '2rem',
height: '2rem',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
fontWeight: '700',
color: '#fff',
flexShrink: '0',
background: COLORS[u.id % COLORS.length],
}"
>{{ initials(u.name) }}</div>
<div style="flex:1;min-width:0">
<div style="font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ u.name }}</div>
<div style="margin-top:0.25rem;height:0.5rem;background:#21262d;border-radius:9999px;overflow:hidden">
<div
:style="{
height: '100%',
borderRadius: '9999px',
background: COLORS[u.id % COLORS.length],
transition: 'width 0.5s ease-out',
width: getBarWidth(u),
}"
/>
</div>
</div>
<div style="font-weight:700;font-size:14px;font-variant-numeric:tabular-nums">{{ u.score.toLocaleString() }}</div>
</div>
</div>
</div>
</div>
</template><script>
import { onDestroy } from "svelte";
const INITIAL_USERS = [
{ id: 1, name: "Alice Smith", score: 9850, prevRank: 1 },
{ id: 2, name: "Bob Johnson", score: 8420, prevRank: 2 },
{ id: 3, name: "Carol Williams", score: 8100, prevRank: 3 },
{ id: 4, name: "David Brown", score: 7650, prevRank: 4 },
{ id: 5, name: "Eve Davis", score: 6980, prevRank: 5 },
{ id: 6, name: "Frank Miller", score: 6200, prevRank: 6 },
{ id: 7, name: "Grace Wilson", score: 5800, prevRank: 7 },
];
const COLORS = ["#818cf8", "#34d399", "#f59e0b", "#f87171", "#a78bfa", "#38bdf8", "#fb7185"];
function initials(name) {
return name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2);
}
let users = INITIAL_USERS.map((u) => ({ ...u }));
let live = false;
let intervalId = null;
let barWidths = {};
$: sorted = [...users].sort((a, b) => b.score - a.score).map((u, i) => ({ ...u, rank: i + 1 }));
$: maxScore = sorted[0].score * 1.1;
function animateBars() {
sorted.forEach((u) => {
barWidths[u.id] = "0%";
});
barWidths = barWidths;
setTimeout(() => {
sorted.forEach((u) => {
barWidths[u.id] = ((u.score / maxScore) * 100).toFixed(1) + "%";
});
barWidths = barWidths;
}, 50);
}
function getBarWidth(u) {
return barWidths[u.id] || ((u.score / maxScore) * 100).toFixed(1) + "%";
}
function toggleLive() {
live = !live;
if (live) {
intervalId = setInterval(() => {
users = users.map((u) => ({
...u,
prevRank: sorted.find((s) => s.id === u.id)?.rank || u.prevRank,
score: u.score + Math.floor(Math.random() * 500),
}));
animateBars();
}, 2500);
} else {
clearInterval(intervalId);
intervalId = null;
}
}
function rankColor(rank) {
if (rank === 1) return "#f59e0b";
if (rank === 2) return "#94a3b8";
if (rank === 3) return "#b45309";
return "#484f58";
}
function rankIcon(rank) {
if (rank <= 3) return ["\u{1F947}", "\u{1F948}", "\u{1F949}"][rank - 1];
return String(rank);
}
function getRankDiff(u) {
return (u.prevRank || u.rank) - u.rank;
}
onDestroy(() => {
if (intervalId) clearInterval(intervalId);
});
</script>
<div style="min-height:100vh;background:#0d1117;padding:1.5rem;display:flex;justify-content:center;font-family:system-ui,-apple-system,sans-serif;color:#e6edf3">
<div style="width:100%;max-width:560px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem">
<h2 style="font-weight:700;font-size:16px;margin:0">Leaderboard</h2>
<button
on:click={toggleLive}
style="padding:0.375rem 0.75rem;border-radius:0.5rem;font-size:12px;font-weight:600;cursor:pointer;border:1px solid {live ? '#34d399' : '#30363d'};background:{live ? 'rgba(52,211,153,0.2)' : 'transparent'};color:{live ? '#34d399' : '#8b949e'}"
>{live ? '\u25A0 Stop Live' : '\u25B6 Start Live'}</button>
</div>
<div style="display:flex;flex-direction:column;gap:0.5rem">
{#each sorted as u (u.id)}
<div style="background:#161b22;border:1px solid #30363d;border-radius:0.75rem;padding:0.75rem 1rem;display:flex;align-items:center;gap:0.75rem">
<div style="width:1.5rem;text-align:center;font-weight:700;font-size:14px;color:{rankColor(u.rank)}">
{rankIcon(u.rank)}
</div>
<div style="font-size:10px;width:2rem;text-align:center">
{#if getRankDiff(u) > 0}
<span style="color:#34d399">\u25B2{getRankDiff(u)}</span>
{:else if getRankDiff(u) < 0}
<span style="color:#f87171">\u25BC{Math.abs(getRankDiff(u))}</span>
{:else}
<span style="color:#484f58">\u2014</span>
{/if}
</div>
<div style="width:2rem;height:2rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff;flex-shrink:0;background:{COLORS[u.id % COLORS.length]}">
{initials(u.name)}
</div>
<div style="flex:1;min-width:0">
<div style="font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{u.name}</div>
<div style="margin-top:0.25rem;height:0.5rem;background:#21262d;border-radius:9999px;overflow:hidden">
<div style="height:100%;border-radius:9999px;background:{COLORS[u.id % COLORS.length]};transition:width 0.5s ease-out;width:{getBarWidth(u)}"></div>
</div>
</div>
<div style="font-weight:700;font-size:14px;font-variant-numeric:tabular-nums">{u.score.toLocaleString()}</div>
</div>
{/each}
</div>
</div>
</div>Features
- Rank medals — gold/silver/bronze icons for top 3 positions
- Score bars — animated fill bars proportional to max score
- Live updates — shuffle scores periodically with smooth re-sort animation
- Avatar initials — auto-generated colored avatars from names
- Delta indicators — ▲▼ arrows showing rank change since last update
How it works
- Entries are sorted by score descending and assigned rank numbers
- Score bars use CSS
widthtransitions for the animated fill - “Live mode” shuffles scores on a timer, re-sorts, and animates position changes
- Rank delta is stored between updates and rendered as a colored badge