02순위가 오르면 스프링으로 튀어 오르는 행
한 칸이라도 순위가 오르면 그 행이 잠깐 커졌다가 튕기듯 제자리 크기로 줄어든다.
쓰임포인트 적립 순위 페이지
LEADERBOARD_RANKUP_OVERSHOOT_PERCENTpopRowLEADERBOARD_RANKUP_POP_MS

<main class="stage">
<div class="lbs is-demo" id="lbs">
<ul class="lbs__list" id="lbsList" role="list"></ul>
<p class="lbs__sr" id="lbsSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: springScale() 이 LEADERBOARD_RANKUP_OVERSHOOT_PERCENT 로부터 감쇠비를 역산해
// 2차계 스텝응답 닫힌 해로 scale 값을 매 프레임 계산한다 — 오버슛 % 를 바꾸면 튀어 오르는
// 정도가 그 값 그대로 달라진다 (속성 트윈이 아니라 계산).
.stage { @include stage($stage-yellow); }
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-cream;
.lbs {
width: min(88vw, 320px);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.lbs__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.lbs__row {
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: $subject-ink;
transform-origin: 50% 50%;
will-change: transform;
cursor: pointer;
}
.lbs__row:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
.lbs__rank {
width: 22px;
flex: 0 0 auto;
font-size: 13px;
font-weight: 700;
color: $subject-blue-light;
text-align: center;
}
.lbs__name {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 700;
color: $color;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lbs__score {
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
color: $color;
font-variant-numeric: tabular-nums;
}
.lbs__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) {
.lbs { width: min(94vw, 260px); }
.lbs__list { gap: $sp-1; }
.lbs__row { padding: $sp-1 $sp-2; gap: $sp-2; }
.lbs__name { font-size: 11px; }
.lbs__score { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.lbs__row { transform: 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lbs {
width: min(88vw, 320px);
display: flex;
flex-direction: column;
gap: 8px;
}
.lbs__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.lbs__row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 8px;
background: #17141a;
transform-origin: 50% 50%;
will-change: transform;
cursor: pointer;
}
.lbs__row:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.lbs__rank {
width: 22px;
flex: 0 0 auto;
font-size: 13px;
font-weight: 700;
color: #5b8cf8;
text-align: center;
}
.lbs__name {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 700;
color: #fff7e6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lbs__score {
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.lbs__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) {
.lbs {
width: min(94vw, 260px);
}
.lbs__list {
gap: 4px;
}
.lbs__row {
padding: 4px 8px;
gap: 8px;
}
.lbs__name {
font-size: 11px;
}
.lbs__score {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.lbs__row {
transform: none !important;
}
}(function () {
"use strict";
var LEADERBOARD_RANKUP_OVERSHOOT_PERCENT = 15;
var LEADERBOARD_RANKUP_POP_MS = 620;
var LEADERBOARD_RANKUP_TICK_MS = 820;
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);
// 계산 겹: 목표 오버슛 비율(OS)로부터 감쇠비 zeta 를 역산해(제어이론의 2차계 스텝응답 공식)
// 그 zeta 로 스프링 곡선을 직접 적분하지 않고 닫힌 해로 계산한다 — 오버슛 % 를 바꾸면
// zeta 가 달라지고 튀어 오르는 정도가 그만큼 정확히 달라진다.
function springScale(t, overshootPercent) {
var os = Math.max(0.001, Math.min(0.9, overshootPercent / 100));
var lnOs = Math.log(os);
var zeta = -lnOs / Math.sqrt(Math.PI * Math.PI + lnOs * lnOs);
var wn = 11.5;
var wd = wn * Math.sqrt(Math.max(0.0001, 1 - zeta * zeta));
var tt = Math.min(1, t) * (LEADERBOARD_RANKUP_POP_MS / 1000);
return 1 - Math.exp(-zeta * wn * tt) * (Math.cos(wd * tt) + (zeta * wn / wd) * Math.sin(wd * tt));
}
var root = document.getElementById("lbs");
var list = document.getElementById("lbsList");
var sr = document.getElementById("lbsSr");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var players = [
{ id: "p1", name: "PULSE_7", score: 970 },
{ id: "p2", name: "VERA_X", score: 935 },
{ id: "p3", name: "KITE_3", score: 900 },
{ id: "p4", name: "ONYX_S", score: 865 },
{ id: "p5", name: "DRIFT9", score: 830 }
];
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; });
}
function relayout() {
var order = sortedPlayers();
order.forEach(function (p, i) {
list.appendChild(nodes[p.id].li);
nodes[p.id].rank.textContent = String(i + 1);
nodes[p.id].score.textContent = String(p.score);
});
return order;
}
function popRow(id) {
var el = nodes[id].li;
if (reduce) return;
var start = null;
function frame(now) {
if (start === null) start = now;
var t = (now - start) / LEADERBOARD_RANKUP_POP_MS;
var s = springScale(t, LEADERBOARD_RANKUP_OVERSHOOT_PERCENT);
el.style.transform = "scale(" + s.toFixed(4) + ")";
if (t < 1) requestAnimationFrame(frame);
else el.style.transform = "scale(1)";
}
requestAnimationFrame(frame);
}
function bump(id) {
var order = sortedPlayers();
var oldRank = order.indexOf(players.filter(function (x) { return x.id === id; })[0]);
var p = players.filter(function (x) { return x.id === id; })[0];
p.score += 25 + Math.floor(rand() * 25);
var newOrder = relayout();
var newRank = newOrder.indexOf(p);
if (newRank < oldRank) {
popRow(id);
sr.textContent = p.name + " 이(가) " + (newRank + 1) + "위로 올랐습니다";
}
}
players.forEach(function (p) {
var li = document.createElement("li");
li.className = "lbs__row";
li.setAttribute("role", "listitem");
li.tabIndex = 0;
var rank = document.createElement("span");
rank.className = "lbs__rank";
var name = document.createElement("span");
name.className = "lbs__name";
name.textContent = p.name;
var score = document.createElement("span");
score.className = "lbs__score";
li.appendChild(rank);
li.appendChild(name);
li.appendChild(score);
nodes[p.id] = { li: li, rank: rank, score: score };
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); }
});
});
relayout();
if (reduce) {
// reduce 에서도 클릭 입력은 relayout() 이 즉시 반영한다 (팝 애니메이션만 생략)
} else {
(function loop() {
if (root.classList.contains("is-demo")) {
var order = sortedPlayers();
var idx = 1 + Math.floor(rand() * (order.length - 1));
var target = order[idx];
var oldRank = idx;
target.score += 35 + Math.floor(rand() * 20);
var newOrder = relayout();
var newRank = newOrder.indexOf(target);
if (newRank < oldRank) popRow(target.id);
}
window.setTimeout(loop, LEADERBOARD_RANKUP_TICK_MS);
})();
}
})();import { useEffect, useRef } from "react";
import styles from "./LeaderboardRankupSpringPop.module.scss";
type Box<T> = { current: T };
const LEADERBOARD_RANKUP_OVERSHOOT_PERCENT = 15;
const LEADERBOARD_RANKUP_POP_MS = 620;
const LEADERBOARD_RANKUP_TICK_MS = 820;
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; rank: HTMLSpanElement; score: HTMLSpanElement };
function springScale(t: number, overshootPercent: number, popMs: number) {
const os = Math.max(0.001, Math.min(0.9, overshootPercent / 100));
const lnOs = Math.log(os);
const zeta = -lnOs / Math.sqrt(Math.PI * Math.PI + lnOs * lnOs);
const wn = 11.5;
const wd = wn * Math.sqrt(Math.max(0.0001, 1 - zeta * zeta));
const tt = Math.min(1, t) * (popMs / 1000);
return 1 - Math.exp(-zeta * wn * tt) * (Math.cos(wd * tt) + ((zeta * wn) / wd) * Math.sin(wd * tt));
}
export default function LeaderboardRankupSpringPop() {
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: "PULSE_7", score: 970 },
{ id: "p2", name: "VERA_X", score: 935 },
{ id: "p3", name: "KITE_3", score: 900 },
{ id: "p4", name: "ONYX_S", score: 865 },
{ id: "p5", name: "DRIFT9", score: 830 }
];
const nodes: Record<string, RowNodes> = {};
let timer: number | undefined;
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);
}
function relayout() {
const order = sortedPlayers();
order.forEach((p, i) => {
list.appendChild(nodes[p.id].li);
nodes[p.id].rank.textContent = String(i + 1);
nodes[p.id].score.textContent = String(p.score);
});
return order;
}
function popRow(id: string) {
const el = nodes[id].li;
if (reduce) return;
let start: number | null = null;
function frame(now: number) {
if (start === null) start = now;
const t = (now - start) / LEADERBOARD_RANKUP_POP_MS;
const s = springScale(t, LEADERBOARD_RANKUP_OVERSHOOT_PERCENT, LEADERBOARD_RANKUP_POP_MS);
el.style.transform = "scale(" + s.toFixed(4) + ")";
if (t < 1) requestAnimationFrame(frame);
else el.style.transform = "scale(1)";
}
requestAnimationFrame(frame);
}
function bump(id: string) {
const order0 = sortedPlayers();
const p = players.filter((x) => x.id === id)[0];
const oldRank = order0.indexOf(p);
p.score += 25 + Math.floor(rand() * 25);
const newOrder = relayout();
const newRank = newOrder.indexOf(p);
if (newRank < oldRank) {
popRow(id);
sr.textContent = p.name + " 이(가) " + (newRank + 1) + "위로 올랐습니다";
}
}
players.forEach((p) => {
const li = document.createElement("li");
li.className = styles.lbs__row;
li.setAttribute("role", "listitem");
li.tabIndex = 0;
const rank = document.createElement("span");
rank.className = styles.lbs__rank;
const name = document.createElement("span");
name.className = styles.lbs__name;
name.textContent = p.name;
const score = document.createElement("span");
score.className = styles.lbs__score;
li.appendChild(rank);
li.appendChild(name);
li.appendChild(score);
nodes[p.id] = { li, rank, score };
li.addEventListener("click", () => {
human();
bump(p.id);
});
li.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
human();
bump(p.id);
}
});
});
relayout();
if (!reduce) {
const loop = () => {
if (root.classList.contains("is-demo")) {
const order = sortedPlayers();
const idx = 1 + Math.floor(rand() * (order.length - 1));
const target = order[idx];
const oldRank = idx;
target.score += 35 + Math.floor(rand() * 20);
const newOrder = relayout();
const newRank = newOrder.indexOf(target);
if (newRank < oldRank) popRow(target.id);
}
timer = window.setTimeout(loop, LEADERBOARD_RANKUP_TICK_MS);
};
timer = window.setTimeout(loop, LEADERBOARD_RANKUP_TICK_MS);
}
return () => {
if (timer) window.clearTimeout(timer);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.lbs + " is-demo"} ref={rootRef}>
<ul className={styles.lbs__list} ref={listRef} role="list" />
<p className={styles.lbs__sr} ref={srRef} aria-live="polite" />
</div>
</main>
);
}02 순위가 오르면 스프링으로 튀어 오르는 행 — 측정 누적면적 23.776% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 랭킹 ui 디자인 9가지 — 랭킹 페이지 디자인도 같은 원리다
이 데모가 속한 글
랭킹 ui 디자인 9가지 — 랭킹 페이지 디자인도 같은 원리다랭킹 ui 디자인은 순위가 바뀌는 순간을 어떻게 보여주느냐의 문제다. 달리기 선수들이 결승선 앞에서 자리를 바꾸는 순간처럼, 순위표도 매번 다른 방식으로 흔들린다 — 01번은 두 행이 서로의 자리를 타고 넘듯 자리를 이 글의 데모 전부 보기


