08점수만큼 자라나는 포디움 막대
각 참가자 아래 막대가 점수 비율만큼 높이로 자라나 순위를 막대 길이로도 보여준다.
쓰임시상식 결과 화면
applyBarsLEADERBOARD_PODIUM_BAR_MAX_PXslotOrder

<main class="stage">
<div class="lbp is-demo" id="lbp">
<div class="lbp__podium" id="lbpPodium" role="list" aria-label="상위 3위 포디움"></div>
<p class="lbp__sr" id="lbpSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html applyBars() 가 점수/1위점수 비율을 scaleY 값으로 그대로 쓴다 —
// 막대는 transform-origin:bottom 고정 높이(96px) 안에서 순수 계산값만큼 자란다.
.stage { @include stage($stage-paper); }
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-ink;
.lbp {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.lbp__podium {
display: flex;
align-items: flex-end;
justify-content: center;
gap: $sp-3;
}
.lbp__col {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
width: 104px;
border-radius: $r-sm;
cursor: pointer;
}
.lbp__col:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
border-radius: $r-xs;
}
.lbp__name {
font-size: 11px;
font-weight: 700;
color: $color;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.lbp__score {
font-size: 11px;
font-weight: 700;
color: $subject-blue-deep;
font-variant-numeric: tabular-nums;
}
.lbp__barwrap {
display: block;
width: 100%;
height: 96px;
display: flex;
align-items: flex-end;
}
.lbp__bar {
display: block;
width: 100%;
height: 100%;
border-radius: $r-xs $r-xs 0 0;
background: $subject-blue;
transform-origin: 50% 100%;
transition: transform $duration $easing;
will-change: transform;
}
.lbp__col--first .lbp__bar {
background: $subject-blue-deep;
}
.lbp__rank {
font-size: 10px;
font-weight: 700;
color: $color;
opacity: .72;
}
.lbp__sr {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip: rect(0 0 0 0); margin: -1px; padding: 0; border: 0;
}
@media (max-width: 340px) {
.lbp__col { width: 72px; }
.lbp__barwrap { height: 76px; }
.lbp__name { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.lbp__bar { transition: none !important; }
}.stage html, .stage body {
margin: 0;
overflow: hidden;
}
.stage .stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lbp {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.lbp__podium {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 12px;
}
.lbp__col {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 104px;
border-radius: 8px;
cursor: pointer;
}
.lbp__col:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
border-radius: 4px;
}
.lbp__name {
font-size: 11px;
font-weight: 700;
color: #17141a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.lbp__score {
font-size: 11px;
font-weight: 700;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
}
.lbp__barwrap {
display: block;
width: 100%;
height: 96px;
display: flex;
align-items: flex-end;
}
.lbp__bar {
display: block;
width: 100%;
height: 100%;
border-radius: 4px 4px 0 0;
background: #2f6df6;
transform-origin: 50% 100%;
transition: transform 600ms ease-out;
will-change: transform;
}
.lbp__col--first .lbp__bar {
background: #0a4fe8;
}
.lbp__rank {
font-size: 10px;
font-weight: 700;
color: #17141a;
opacity: 0.72;
}
.lbp__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
margin: -1px;
padding: 0;
border: 0;
}
@media (max-width: 340px) {
.lbp__col {
width: 72px;
}
.lbp__barwrap {
height: 76px;
}
.lbp__name {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.lbp__bar {
transition: none !important;
}
}(function () {
"use strict";
var LEADERBOARD_PODIUM_BAR_MAX_PX = 96;
// 시연 스케줄: 2초 구간 안에서 점수 갱신이 여러 번 겹쳐 일어나 막대가 크게 오르내리게 하는 고정 시각표.
// rAF 타임스탬프 차이(dt)만 쓴다 — new Date·Date.now·Math.random 없음.
// 첫 항목을 150ms 뒤로 둔다 — 렌더 하네스가 로드 150ms 시점에 진행 중인 CSS 전환(transition)이
// 있으면 "결정론 모드"(프레임당 실시간 20ms만 허용)로 바뀌어 시연 프레임 수가 줄어든다(실측).
var DEMO_SCHEDULE = [
{ t: 200, id: "p3" }, { t: 560, id: "p2" }, { t: 920, id: "p1" },
{ t: 1280, id: "p3" }, { t: 1640, id: "p2" }
];
var DEMO_LOOP_MS = 2000;
var BAR_LO_RGB = [47, 109, 246]; // $subject-blue
var BAR_HI_RGB = [10, 79, 232]; // $subject-blue-deep
function seedRand(seed) {
var s = seed % 2147483647; if (s <= 0) s += 2147483646;
return function () { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };
}
var rand = seedRand(418);
var root = document.getElementById("lbp");
var podium = document.getElementById("lbpPodium");
var sr = document.getElementById("lbpSr");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var players = [
{ id: "p1", name: "SABER4", score: 640 },
{ id: "p2", name: "MYRTLE2", score: 590 },
{ id: "p3", name: "KESTREL9", score: 540 }
];
var slotOrder = [1, 0, 2]; // 2위-1위-3위 배치 (가운데가 1위)
var nodes = {};
function human() {
if (root.classList.contains("is-demo")) root.classList.remove("is-demo");
}
function sortedPlayers() {
return players.slice().sort(function (a, b) { return b.score - a.score; });
}
// 계산 겹: 각 선수의 점수를 1위 점수로 나눈 비율(ratio)이 막대의 scaleY 그 자체다 —
// transform-origin 을 바닥에 두어 레이아웃을 건드리지 않고 순수 계산값만으로 키를 만든다.
// 같은 ratio 를 막대 명도(lo→hi 보간)·바닥 그림자·1위 기둥 배경에도 전파한다(계산 겹).
function applyBars(order) {
var maxScore = order[0].score || 1;
order.forEach(function (p, i) {
var ratio = Math.max(0.06, p.score / maxScore);
var node = nodes[p.id];
node.bar.style.transform = "scaleY(" + ratio.toFixed(3) + ")";
node.rank.textContent = String(i + 1);
node.score.textContent = String(p.score);
var r = Math.round(BAR_LO_RGB[0] + (BAR_HI_RGB[0] - BAR_LO_RGB[0]) * ratio);
var g = Math.round(BAR_LO_RGB[1] + (BAR_HI_RGB[1] - BAR_LO_RGB[1]) * ratio);
var b = Math.round(BAR_LO_RGB[2] + (BAR_HI_RGB[2] - BAR_LO_RGB[2]) * ratio);
node.bar.style.background = "rgb(" + r + "," + g + "," + b + ")";
node.wrap.style.boxShadow = "0 " + (ratio * 9).toFixed(1) + "px " + (ratio * 20).toFixed(1) + "px rgba(10,10,14," + (ratio * 0.34).toFixed(3) + ")";
node.col.style.background = "rgba(" + BAR_HI_RGB.join(",") + "," + (Math.max(0, ratio - 0.45) * 0.16).toFixed(3) + ")";
});
}
function relayout(announce) {
var order = sortedPlayers();
order.forEach(function (p, i) {
var slot = slotOrder.indexOf(i);
nodes[p.id].col.style.order = String(slot);
nodes[p.id].col.classList.toggle("lbp__col--first", i === 0);
});
applyBars(order);
if (announce) sr.textContent = order[0].name + " 이(가) 1위, 점수 " + order[0].score;
}
function bump(id) {
var p = players.filter(function (x) { return x.id === id; })[0];
p.score += 20 + Math.floor(rand() * 35);
relayout(true);
}
players.forEach(function (p) {
var col = document.createElement("div");
col.className = "lbp__col";
col.setAttribute("role", "listitem");
col.tabIndex = 0;
var name = document.createElement("span");
name.className = "lbp__name";
name.textContent = p.name;
var score = document.createElement("span");
score.className = "lbp__score";
var barWrap = document.createElement("span");
barWrap.className = "lbp__barwrap";
var bar = document.createElement("span");
bar.className = "lbp__bar";
barWrap.appendChild(bar);
var rank = document.createElement("span");
rank.className = "lbp__rank";
col.appendChild(name);
col.appendChild(score);
col.appendChild(barWrap);
col.appendChild(rank);
nodes[p.id] = { col: col, name: name, score: score, bar: bar, wrap: barWrap, rank: rank };
col.addEventListener("click", function () { human(); bump(p.id); });
col.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); human(); bump(p.id); }
});
podium.appendChild(col);
});
relayout(false);
function scheduledBump(id) {
var p = players.filter(function (x) { return x.id === id; })[0];
var maxScore = players.reduce(function (m, x) { return Math.max(m, x.score); }, 1);
var delta = Math.round(maxScore * (0.2 + rand() * 0.4));
p.score += delta;
relayout(true);
}
if (!reduce) {
var demoElapsed = 0, demoLast = null, demoIdx = 0, demoLap = 0;
function demoLoop(ts) {
if (root.classList.contains("is-demo")) {
if (demoLast === null) demoLast = ts;
demoElapsed += ts - demoLast;
demoLast = ts;
while (true) {
var entry = DEMO_SCHEDULE[demoIdx];
var target = demoLap * DEMO_LOOP_MS + entry.t;
if (demoElapsed < target) break;
scheduledBump(entry.id);
demoIdx++;
if (demoIdx >= DEMO_SCHEDULE.length) { demoIdx = 0; demoLap++; }
}
} else {
demoLast = null;
}
requestAnimationFrame(demoLoop);
}
requestAnimationFrame(demoLoop);
}
})();import { useEffect, useRef } from "react";
import styles from "./LeaderboardPodiumBarGrow.module.scss";
type Box<T> = { current: T };
const LEADERBOARD_PODIUM_BAR_MAX_PX = 96;
// 시연 스케줄: 2초 구간 안에서 점수 갱신이 여러 번 겹쳐 일어나 막대가 크게 오르내리게 하는 고정 시각표.
// rAF 타임스탬프 차이(dt)만 쓴다 — new Date·Date.now·Math.random 없음.
// 첫 항목을 150ms 뒤로 둔다 — 렌더 하네스가 로드 150ms 시점에 진행 중인 CSS 전환(transition)이
// 있으면 "결정론 모드"(프레임당 실시간 20ms만 허용)로 바뀌어 시연 프레임 수가 줄어든다(실측).
const DEMO_SCHEDULE: { t: number; id: string }[] = [
{ t: 200, id: "p3" }, { t: 560, id: "p2" }, { t: 920, id: "p1" },
{ t: 1280, id: "p3" }, { t: 1640, id: "p2" }
];
const DEMO_LOOP_MS = 2000;
const BAR_LO_RGB: [number, number, number] = [47, 109, 246]; // $subject-blue
const BAR_HI_RGB: [number, number, number] = [10, 79, 232]; // $subject-blue-deep
function seedRand(seed: number) {
let s = seed % 2147483647;
if (s <= 0) s += 2147483646;
return function () {
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
}
type Player = { id: string; name: string; score: number };
type ColNodes = { col: HTMLDivElement; name: HTMLSpanElement; score: HTMLSpanElement; bar: HTMLSpanElement; wrap: HTMLSpanElement; rank: HTMLSpanElement };
export default function LeaderboardPodiumBarGrow() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const podiumRef = useRef(null) as unknown as Box<HTMLDivElement>;
const srRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
useEffect(() => {
const root = rootRef.current;
const podium = podiumRef.current;
const sr = srRef.current;
const rand = seedRand(418);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const players: Player[] = [
{ id: "p1", name: "SABER4", score: 640 },
{ id: "p2", name: "MYRTLE2", score: 590 },
{ id: "p3", name: "KESTREL9", score: 540 }
];
const slotOrder = [1, 0, 2];
const nodes: Record<string, ColNodes> = {};
let raf: number | undefined;
void LEADERBOARD_PODIUM_BAR_MAX_PX;
function human() {
if (root.classList.contains("is-demo")) root.classList.remove("is-demo");
}
function sortedPlayers() {
return players.slice().sort((a, b) => b.score - a.score);
}
// 계산 겹: 각 선수의 점수를 1위 점수로 나눈 비율(ratio)이 막대의 scaleY 그 자체다 —
// transform-origin 을 바닥에 두어 레이아웃을 건드리지 않고 순수 계산값만으로 키를 만든다.
// 같은 ratio 를 막대 명도(lo→hi 보간)·바닥 그림자·1위 기둥 배경에도 전파한다(계산 겹).
function applyBars(order: Player[]) {
const maxScore = order[0].score || 1;
order.forEach((p, i) => {
const ratio = Math.max(0.06, p.score / maxScore);
const node = nodes[p.id];
node.bar.style.transform = "scaleY(" + ratio.toFixed(3) + ")";
node.rank.textContent = String(i + 1);
node.score.textContent = String(p.score);
const r = Math.round(BAR_LO_RGB[0] + (BAR_HI_RGB[0] - BAR_LO_RGB[0]) * ratio);
const g = Math.round(BAR_LO_RGB[1] + (BAR_HI_RGB[1] - BAR_LO_RGB[1]) * ratio);
const b = Math.round(BAR_LO_RGB[2] + (BAR_HI_RGB[2] - BAR_LO_RGB[2]) * ratio);
node.bar.style.background = "rgb(" + r + "," + g + "," + b + ")";
node.wrap.style.boxShadow = "0 " + (ratio * 9).toFixed(1) + "px " + (ratio * 20).toFixed(1) + "px rgba(10,10,14," + (ratio * 0.34).toFixed(3) + ")";
node.col.style.background = "rgba(" + BAR_HI_RGB.join(",") + "," + (Math.max(0, ratio - 0.45) * 0.16).toFixed(3) + ")";
});
}
function relayout(announce: boolean) {
const order = sortedPlayers();
order.forEach((p, i) => {
const slot = slotOrder.indexOf(i);
nodes[p.id].col.style.order = String(slot);
nodes[p.id].col.classList.toggle(styles["lbp__col--first"], i === 0);
});
applyBars(order);
if (announce) sr.textContent = order[0].name + " 이(가) 1위, 점수 " + order[0].score;
}
function bump(id: string) {
const p = players.filter((x) => x.id === id)[0];
p.score += 20 + Math.floor(rand() * 35);
relayout(true);
}
players.forEach((p) => {
const col = document.createElement("div");
col.className = styles.lbp__col;
col.setAttribute("role", "listitem");
col.tabIndex = 0;
const name = document.createElement("span");
name.className = styles.lbp__name;
name.textContent = p.name;
const score = document.createElement("span");
score.className = styles.lbp__score;
const barWrap = document.createElement("span");
barWrap.className = styles.lbp__barwrap;
const bar = document.createElement("span");
bar.className = styles.lbp__bar;
barWrap.appendChild(bar);
const rank = document.createElement("span");
rank.className = styles.lbp__rank;
col.appendChild(name);
col.appendChild(score);
col.appendChild(barWrap);
col.appendChild(rank);
nodes[p.id] = { col, name, score, bar, wrap: barWrap, rank };
col.addEventListener("click", () => { human(); bump(p.id); });
col.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); human(); bump(p.id); }
});
podium.appendChild(col);
});
relayout(false);
function scheduledBump(id: string) {
const p = players.filter((x) => x.id === id)[0];
const maxScore = players.reduce((m, x) => Math.max(m, x.score), 1);
const delta = Math.round(maxScore * (0.2 + rand() * 0.4));
p.score += delta;
relayout(true);
}
if (!reduce) {
let demoElapsed = 0, demoLast: number | null = null, demoIdx = 0, demoLap = 0;
const demoLoop = (ts: number) => {
if (root.classList.contains("is-demo")) {
if (demoLast === null) demoLast = ts;
demoElapsed += ts - demoLast;
demoLast = ts;
while (true) {
const entry = DEMO_SCHEDULE[demoIdx];
const target = demoLap * DEMO_LOOP_MS + entry.t;
if (demoElapsed < target) break;
scheduledBump(entry.id);
demoIdx++;
if (demoIdx >= DEMO_SCHEDULE.length) { demoIdx = 0; demoLap++; }
}
} else {
demoLast = null;
}
raf = requestAnimationFrame(demoLoop);
};
raf = requestAnimationFrame(demoLoop);
}
return () => {
if (raf) cancelAnimationFrame(raf);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.lbp + " is-demo"} ref={rootRef}>
<div className={styles.lbp__podium} ref={podiumRef} role="list" aria-label="상위 3위 포디움" />
<p className={styles.lbp__sr} ref={srRef} aria-live="polite" />
</div>
</main>
);
}08 점수만큼 자라나는 포디움 막대 — 측정 누적면적 18.14% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 랭킹 ui 디자인 9가지 — 랭킹 페이지 디자인도 같은 원리다
이 데모가 속한 글
랭킹 ui 디자인 9가지 — 랭킹 페이지 디자인도 같은 원리다랭킹 ui 디자인은 순위가 바뀌는 순간을 어떻게 보여주느냐의 문제다. 달리기 선수들이 결승선 앞에서 자리를 바꾸는 순간처럼, 순위표도 매번 다른 방식으로 흔들린다 — 01번은 두 행이 서로의 자리를 타고 넘듯 자리를 이 글의 데모 전부 보기


