03점수 숫자가 한 자리씩 굴러 올라가는 카운터
점수가 오르면 각 자릿수가 슬롯머신처럼 따로 굴러 새 숫자로 멈춘다.
쓰임라이브 점수 대시보드
LEADERBOARD_DIGIT_ROLL_STAGGER_MSbuildReelLEADERBOARD_DIGIT_H

<main class="stage">
<div class="lbd is-demo" id="lbd">
<ul class="lbd__list" id="lbdList" role="list"></ul>
<p class="lbd__sr" id="lbdSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: rollTo() 가 자릿수 순번(i) x LEADERBOARD_DIGIT_ROLL_STAGGER_MS 를 각 reel 의
// transitionDelay 로 걸어, 한 자리씩 슬롯머신처럼 다른 시점에 새 숫자로 멈춘다.
.stage { @include stage($stage-orange); }
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-cream;
.lbd {
width: min(88vw, 320px);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.lbd__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.lbd__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-3;
padding: $sp-1 $sp-3;
border-radius: $r-sm;
background: $subject-ink;
cursor: pointer;
}
.lbd__row.is-hot {
animation: lbd-row-glow var(--row-glow-ms, 500ms) ease-out;
}
@keyframes lbd-row-glow {
0% { background: $subject-ink; box-shadow: 0 0 0 rgba($subject-blue-light, 0); }
30% { background: rgba($subject-blue-light, .22); box-shadow: 0 0 20px rgba($subject-blue-light, .3); }
100% { background: $subject-ink; box-shadow: 0 0 0 rgba($subject-blue-light, 0); }
}
.lbd__row:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
.lbd__name {
font-size: 12px;
font-weight: 700;
color: $color;
}
.lbd__score {
display: flex;
gap: 0;
}
.lbd__digit {
width: 18px;
height: 22px;
overflow: hidden;
position: relative;
border-radius: $r-xs;
}
.lbd__digit.is-rolling {
animation: lbd-roll-glow var(--roll-ms, 380ms) ease-out;
animation-delay: var(--roll-delay, 0ms);
}
@keyframes lbd-roll-glow {
0% { background: rgba($subject-blue-light, 0); box-shadow: 0 0 0 rgba($subject-blue-light, 0); }
35% { background: rgba($subject-blue-light, .42); box-shadow: 0 0 16px rgba($subject-blue-light, .55); }
100% { background: rgba($subject-blue-light, 0); box-shadow: 0 0 0 rgba($subject-blue-light, 0); }
}
.lbd__reel {
position: absolute;
left: 0;
top: 0;
width: 100%;
transition: transform $duration $easing;
}
.lbd__reel span {
display: block;
height: 22px;
line-height: 22px;
text-align: center;
font-size: 16px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: $color;
}
.lbd__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) {
.lbd { width: min(94vw, 260px); }
.lbd__list { gap: $sp-1; }
.lbd__row { padding: $sp-1 $sp-2; gap: $sp-2; }
.lbd__name { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.lbd__reel { transition: none !important; }
.lbd__digit.is-rolling { animation: none !important; }
.lbd__row.is-hot { animation: 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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lbd {
width: min(88vw, 320px);
display: flex;
flex-direction: column;
gap: 8px;
}
.lbd__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.lbd__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 4px 12px;
border-radius: 8px;
background: #17141a;
cursor: pointer;
}
.lbd__row.is-hot {
animation: lbd-row-glow var(--row-glow-ms, 500ms) ease-out;
}
@keyframes lbd-row-glow {
0% {
background: #17141a;
box-shadow: 0 0 0 rgba(91, 140, 248, 0);
}
30% {
background: rgba(91, 140, 248, 0.22);
box-shadow: 0 0 20px rgba(91, 140, 248, 0.3);
}
100% {
background: #17141a;
box-shadow: 0 0 0 rgba(91, 140, 248, 0);
}
}
.lbd__row:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.lbd__name {
font-size: 12px;
font-weight: 700;
color: #fff7e6;
}
.lbd__score {
display: flex;
gap: 0;
}
.lbd__digit {
width: 18px;
height: 22px;
overflow: hidden;
position: relative;
border-radius: 4px;
}
.lbd__digit.is-rolling {
animation: lbd-roll-glow var(--roll-ms, 380ms) ease-out;
animation-delay: var(--roll-delay, 0ms);
}
@keyframes lbd-roll-glow {
0% {
background: rgba(91, 140, 248, 0);
box-shadow: 0 0 0 rgba(91, 140, 248, 0);
}
35% {
background: rgba(91, 140, 248, 0.42);
box-shadow: 0 0 16px rgba(91, 140, 248, 0.55);
}
100% {
background: rgba(91, 140, 248, 0);
box-shadow: 0 0 0 rgba(91, 140, 248, 0);
}
}
.lbd__reel {
position: absolute;
left: 0;
top: 0;
width: 100%;
transition: transform 300ms ease-out;
}
.lbd__reel span {
display: block;
height: 22px;
line-height: 22px;
text-align: center;
font-size: 16px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.lbd__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) {
.lbd {
width: min(94vw, 260px);
}
.lbd__list {
gap: 4px;
}
.lbd__row {
padding: 4px 8px;
gap: 8px;
}
.lbd__name {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.lbd__reel {
transition: none !important;
}
.lbd__digit.is-rolling {
animation: none !important;
}
.lbd__row.is-hot {
animation: none !important;
}
}(function () {
"use strict";
var LEADERBOARD_DIGIT_ROLL_STAGGER_MS = 30;
var LEADERBOARD_DIGIT_ROLL_MS = 380;
var LEADERBOARD_DIGIT_H = 22;
var LEADERBOARD_DIGIT_TICK_MS = 300;
var LEADERBOARD_DIGIT_ROLL_HIGHLIGHT = "91,140,248";
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("lbd");
var list = document.getElementById("lbdList");
var sr = document.getElementById("lbdSr");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var players = [
{ id: "p1", name: "AXIS_4", score: 780 },
{ id: "p2", name: "MIRA_Q", score: 742 },
{ id: "p3", name: "TROVE1", score: 705 },
{ id: "p4", name: "HALO_9", score: 668 },
{ id: "p5", name: "SABLE2", score: 631 }
];
var nodes = {};
function human() {
if (root.classList.contains("is-demo")) root.classList.remove("is-demo");
}
function buildReel(container) {
var digits = [];
for (var d = 0; d <= 9; d++) {
var s = document.createElement("span");
s.textContent = String(d);
container.appendChild(s);
digits.push(s);
}
return digits;
}
// 계산 겹: rollTo() 가 점수 숫자를 3자리로 나눠, 왼쪽부터 자릿수 순번(i) x
// LEADERBOARD_DIGIT_ROLL_STAGGER_MS 를 그 자릿수 reel 의 transitionDelay 로 걸어
// 한꺼번에 목표 값으로 바꾼다 — 값을 늘리면 자리마다 굴러가는 시간차가 그만큼 커진다.
// 실제로 값이 바뀐 자릿수 칸에는 굴러가는 진행 구간과 같은 길이의 배경 하이라이트를
// 얹어(is-rolling), 숫자가 도는 셀이 눈에 띄게 밝아졌다 가라앉게 한다.
function rollTo(row, score) {
var str = String(score);
while (str.length < 3) str = "0" + str;
var changed = false;
for (var i = 0; i < 3; i++) {
var digit = parseInt(str[i], 10);
var delay = reduce ? 0 : i * LEADERBOARD_DIGIT_ROLL_STAGGER_MS;
var dur = reduce ? 0 : LEADERBOARD_DIGIT_ROLL_MS;
var reel = row.reels[i];
var box = row.digitBoxes[i];
reel.style.transitionDelay = delay + "ms";
reel.style.transitionDuration = dur + "ms";
reel.style.transform = "translateY(-" + (digit * LEADERBOARD_DIGIT_H) + "px)";
if (!reduce && row.prevDigits[i] !== digit) {
changed = true;
box.style.setProperty("--roll-delay", delay + "ms");
box.style.setProperty("--roll-ms", dur + "ms");
box.classList.remove("is-rolling");
void box.offsetWidth;
box.classList.add("is-rolling");
window.setTimeout(function (b) { return function () { b.classList.remove("is-rolling"); }; }(box), delay + dur + 60);
}
row.prevDigits[i] = digit;
}
// 계산 겹: 자릿수 하나라도 바뀌면 그 진행 구간(자릿수 3개가 전부 굴러 멈추는
// 시점까지) 만큼 행 전체 배경에도 옅은 톤을 얹어, 작은 자릿수 칸을 넘어 행 폭
// 전체로 진행률을 전파한다.
if (!reduce && changed) {
var span = 2 * LEADERBOARD_DIGIT_ROLL_STAGGER_MS + LEADERBOARD_DIGIT_ROLL_MS + 60;
row.li.style.setProperty("--row-glow-ms", span + "ms");
row.li.classList.remove("is-hot");
void row.li.offsetWidth;
row.li.classList.add("is-hot");
window.setTimeout(function (li) { return function () { li.classList.remove("is-hot"); }; }(row.li), span);
}
}
function bump(id) {
var p = players.filter(function (x) { return x.id === id; })[0];
p.score = Math.min(999, p.score + 30 + Math.floor(rand() * 70));
rollTo(nodes[id], p.score);
sr.textContent = p.name + " 점수 " + p.score + "점";
}
// 한 틱에 서로 다른 참가자 여러 명의 점수를 동시에 올려, 여러 행의 여러 자릿수가
// 한꺼번에 돌아가게 한다.
function bumpMany() {
var pool = players.slice();
var picks = [];
for (var n = 0; n < 5 && pool.length; n++) {
var i = Math.floor(rand() * pool.length);
picks.push(pool.splice(i, 1)[0]);
}
picks.forEach(function (p) {
p.score = Math.min(999, p.score + 55 + Math.floor(rand() * 120));
rollTo(nodes[p.id], p.score);
});
}
players.forEach(function (p) {
var li = document.createElement("li");
li.className = "lbd__row";
li.setAttribute("role", "listitem");
li.tabIndex = 0;
var name = document.createElement("span");
name.className = "lbd__name";
name.textContent = p.name;
var scoreWrap = document.createElement("span");
scoreWrap.className = "lbd__score";
var reels = [];
var digitBoxes = [];
for (var i = 0; i < 3; i++) {
var digitBox = document.createElement("span");
digitBox.className = "lbd__digit";
var reel = document.createElement("span");
reel.className = "lbd__reel";
buildReel(reel);
digitBox.appendChild(reel);
scoreWrap.appendChild(digitBox);
reels.push(reel);
digitBoxes.push(digitBox);
}
li.appendChild(name);
li.appendChild(scoreWrap);
var initStr = String(p.score);
while (initStr.length < 3) initStr = "0" + initStr;
nodes[p.id] = {
li: li, reels: reels, digitBoxes: digitBoxes,
prevDigits: [parseInt(initStr[0], 10), parseInt(initStr[1], 10), parseInt(initStr[2], 10)]
};
li.addEventListener("click", function () { human(); bump(p.id); });
li.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); human(); bump(p.id); }
});
list.appendChild(li);
});
players.forEach(function (p) { rollTo(nodes[p.id], p.score); });
// 최초 배치는 전이 없이 바로 자리 잡는다
Object.keys(nodes).forEach(function (id) {
nodes[id].reels.forEach(function (r) { r.style.transitionDuration = "0ms"; });
});
window.setTimeout(function () {
Object.keys(nodes).forEach(function (id) {
nodes[id].reels.forEach(function (r) { r.style.transitionDuration = ""; });
});
}, 30);
if (!reduce) {
(function loop() {
if (root.classList.contains("is-demo")) {
bumpMany();
}
window.setTimeout(loop, LEADERBOARD_DIGIT_TICK_MS);
})();
}
})();import { useEffect, useRef } from "react";
import styles from "./LeaderboardScoreDigitRoll.module.scss";
type Box<T> = { current: T };
const LEADERBOARD_DIGIT_ROLL_STAGGER_MS = 30;
const LEADERBOARD_DIGIT_ROLL_MS = 380;
const LEADERBOARD_DIGIT_H = 22;
const LEADERBOARD_DIGIT_TICK_MS = 300;
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 RowNodes = {
li: HTMLLIElement;
reels: HTMLSpanElement[];
digitBoxes: HTMLSpanElement[];
prevDigits: number[];
};
export default function LeaderboardScoreDigitRoll() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLUListElement>;
const srRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
useEffect(() => {
const root = rootRef.current;
const list = listRef.current;
const sr = srRef.current;
const rand = seedRand(418);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const players: Player[] = [
{ id: "p1", name: "AXIS_4", score: 780 },
{ id: "p2", name: "MIRA_Q", score: 742 },
{ id: "p3", name: "TROVE1", score: 705 },
{ id: "p4", name: "HALO_9", score: 668 },
{ id: "p5", name: "SABLE2", score: 631 }
];
const nodes: Record<string, RowNodes> = {};
let timer: number | undefined;
function human() {
if (root.classList.contains("is-demo")) root.classList.remove("is-demo");
}
function buildReel(container: HTMLElement) {
for (let d = 0; d <= 9; d++) {
const s = document.createElement("span");
s.textContent = String(d);
container.appendChild(s);
}
}
function rollTo(row: RowNodes, score: number) {
let str = String(score);
while (str.length < 3) str = "0" + str;
let changed = false;
for (let i = 0; i < 3; i++) {
const digit = parseInt(str[i], 10);
const delay = reduce ? 0 : i * LEADERBOARD_DIGIT_ROLL_STAGGER_MS;
const dur = reduce ? 0 : LEADERBOARD_DIGIT_ROLL_MS;
const reel = row.reels[i];
const box = row.digitBoxes[i];
reel.style.transitionDelay = delay + "ms";
reel.style.transitionDuration = dur + "ms";
reel.style.transform = "translateY(-" + digit * LEADERBOARD_DIGIT_H + "px)";
if (!reduce && row.prevDigits[i] !== digit) {
changed = true;
box.style.setProperty("--roll-delay", delay + "ms");
box.style.setProperty("--roll-ms", dur + "ms");
box.classList.remove("is-rolling");
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
box.offsetWidth;
box.classList.add("is-rolling");
window.setTimeout(() => box.classList.remove("is-rolling"), delay + dur + 60);
}
row.prevDigits[i] = digit;
}
if (!reduce && changed) {
const span = 2 * LEADERBOARD_DIGIT_ROLL_STAGGER_MS + LEADERBOARD_DIGIT_ROLL_MS + 60;
row.li.style.setProperty("--row-glow-ms", span + "ms");
row.li.classList.remove("is-hot");
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
row.li.offsetWidth;
row.li.classList.add("is-hot");
window.setTimeout(() => row.li.classList.remove("is-hot"), span);
}
}
function bump(id: string) {
const p = players.filter((x) => x.id === id)[0];
p.score = Math.min(999, p.score + 30 + Math.floor(rand() * 70));
rollTo(nodes[id], p.score);
sr.textContent = p.name + " 점수 " + p.score + "점";
}
// 한 틱에 서로 다른 참가자 여러 명의 점수를 동시에 올려, 여러 행의 여러 자릿수가
// 한꺼번에 돌아가게 한다.
function bumpMany() {
const pool = players.slice();
const picks: Player[] = [];
for (let n = 0; n < 5 && pool.length; n++) {
const i = Math.floor(rand() * pool.length);
picks.push(pool.splice(i, 1)[0]);
}
picks.forEach((p) => {
p.score = Math.min(999, p.score + 55 + Math.floor(rand() * 120));
rollTo(nodes[p.id], p.score);
});
}
players.forEach((p) => {
const li = document.createElement("li");
li.className = styles.lbd__row;
li.setAttribute("role", "listitem");
li.tabIndex = 0;
const name = document.createElement("span");
name.className = styles.lbd__name;
name.textContent = p.name;
const scoreWrap = document.createElement("span");
scoreWrap.className = styles.lbd__score;
const reels: HTMLSpanElement[] = [];
const digitBoxes: HTMLSpanElement[] = [];
for (let i = 0; i < 3; i++) {
const digitBox = document.createElement("span");
digitBox.className = styles.lbd__digit;
const reel = document.createElement("span");
reel.className = styles.lbd__reel;
buildReel(reel);
digitBox.appendChild(reel);
scoreWrap.appendChild(digitBox);
reels.push(reel);
digitBoxes.push(digitBox);
}
li.appendChild(name);
li.appendChild(scoreWrap);
let initStr = String(p.score);
while (initStr.length < 3) initStr = "0" + initStr;
nodes[p.id] = {
li, reels, digitBoxes,
prevDigits: [parseInt(initStr[0], 10), parseInt(initStr[1], 10), parseInt(initStr[2], 10)]
};
li.addEventListener("click", () => {
human();
bump(p.id);
});
li.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
human();
bump(p.id);
}
});
list.appendChild(li);
});
players.forEach((p) => rollTo(nodes[p.id], p.score));
Object.keys(nodes).forEach((id) => {
nodes[id].reels.forEach((r) => {
r.style.transitionDuration = "0ms";
});
});
window.setTimeout(() => {
Object.keys(nodes).forEach((id) => {
nodes[id].reels.forEach((r) => {
r.style.transitionDuration = "";
});
});
}, 30);
if (!reduce) {
const loop = () => {
if (root.classList.contains("is-demo")) {
bumpMany();
}
timer = window.setTimeout(loop, LEADERBOARD_DIGIT_TICK_MS);
};
timer = window.setTimeout(loop, LEADERBOARD_DIGIT_TICK_MS);
}
return () => {
if (timer) window.clearTimeout(timer);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.lbd + " is-demo"} ref={rootRef}>
<ul className={styles.lbd__list} ref={listRef} role="list" />
<p className={styles.lbd__sr} ref={srRef} aria-live="polite" />
</div>
</main>
);
}03 점수 숫자가 한 자리씩 굴러 올라가는 카운터 — 측정 누적면적 41.486% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 랭킹 ui 디자인 9가지 — 랭킹 페이지 디자인도 같은 원리다
이 데모가 속한 글
랭킹 ui 디자인 9가지 — 랭킹 페이지 디자인도 같은 원리다랭킹 ui 디자인은 순위가 바뀌는 순간을 어떻게 보여주느냐의 문제다. 달리기 선수들이 결승선 앞에서 자리를 바꾸는 순간처럼, 순위표도 매번 다른 방식으로 흔들린다 — 01번은 두 행이 서로의 자리를 타고 넘듯 자리를 이 글의 데모 전부 보기


