07오르내림을 알려주는 화살표 배지
순위가 바뀔 때마다 오른 만큼은 위쪽 화살표로, 내린 만큼은 아래쪽 화살표로 잠깐 반짝였다 사라진다.
쓰임주간 순위 변동 리포트
flashArrowLEADERBOARD_DELTA_DECAY_TAU_MSLEADERBOARD_DELTA_MAX_REF

<main class="stage">
<div class="lbf is-demo" id="lbf">
<ul class="lbf__list" id="lbfList" role="list"></ul>
<p class="lbf__sr" id="lbfSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html flashArrow() 가 변화량 부호로 방향(rotate)을, 크기 비율로 배율(scale)을
// 정하고 지수감쇠로 opacity 를 매 프레임 계산한다 — 여기는 그 값을 받는 transform·opacity 만.
.stage { @include stage($stage-orange); }
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-cream;
.lbf {
width: min(88vw, 320px);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.lbf__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.lbf__row {
position: relative;
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-2 $sp-3;
padding-right: $sp-8;
border-radius: $r-sm;
background: $subject-ink;
cursor: pointer;
}
.lbf__row:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
.lbf__name {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 700;
color: $color;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lbf__score {
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
color: $color;
font-variant-numeric: tabular-nums;
}
.lbf__arrow {
position: absolute;
top: 50%;
right: $sp-3;
width: 12px;
height: 12px;
background: $subject-blue-light;
clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
opacity: 0;
transform: translateY(-50%);
transition: opacity $duration $easing;
will-change: opacity, transform;
}
.lbf__arrow--down {
background: $error;
}
.lbf__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) {
.lbf { width: min(94vw, 260px); }
.lbf__list { gap: $sp-1; }
.lbf__row { padding: $sp-1 $sp-2; padding-right: $sp-6; gap: $sp-2; }
.lbf__name { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.lbf__arrow { 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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lbf {
width: min(88vw, 320px);
display: flex;
flex-direction: column;
gap: 8px;
}
.lbf__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.lbf__row {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
padding-right: 32px;
border-radius: 8px;
background: #17141a;
cursor: pointer;
}
.lbf__row:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.lbf__name {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 700;
color: #fff7e6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lbf__score {
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.lbf__arrow {
position: absolute;
top: 50%;
right: 12px;
width: 12px;
height: 12px;
background: #5b8cf8;
clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
opacity: 0;
transform: translateY(-50%);
transition: opacity 220ms ease-out;
will-change: opacity, transform;
}
.lbf__arrow--down {
background: #c2361a;
}
.lbf__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) {
.lbf {
width: min(94vw, 260px);
}
.lbf__list {
gap: 4px;
}
.lbf__row {
padding: 4px 8px;
padding-right: 24px;
gap: 8px;
}
.lbf__name {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.lbf__arrow {
transition: none !important;
}
}(function () {
"use strict";
var LEADERBOARD_DELTA_DECAY_TAU_MS = 260;
var LEADERBOARD_DELTA_FLOOR = 0.03;
var LEADERBOARD_DELTA_MAX_REF = 40;
// 시연 스케줄: 2초 구간 안에서 여러 행의 순위 변동이 겹쳐 일어나게 하는 고정 시각표.
// rAF 타임스탬프 차이(dt)만 쓴다 — new Date·Date.now·Math.random 없음.
var DEMO_SCHEDULE = [
{ t: 0, id: "p2" }, { t: 220, id: "p4" }, { t: 460, id: "p1" }, { t: 740, id: "p3" },
{ t: 1000, id: "p5" }, { t: 1280, id: "p2" }, { t: 1560, id: "p4" }, { t: 1840, id: "p1" }
];
var DEMO_LOOP_MS = 2000;
var UP_RGB = "91,140,255"; // $subject-blue-light 와 맞춘다(html 스크립트는 scss 토큰을 못 읽는다)
var DOWN_RGB = "194,54,26"; // $error
var INK_HEX = "#17141a"; // $subject-ink — 플래시 겹칠 때도 바탕을 유지
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("lbf");
var list = document.getElementById("lbfList");
var sr = document.getElementById("lbfSr");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var players = [
{ id: "p1", name: "ONYX_4", score: 860 },
{ id: "p2", name: "LUMEN7", score: 828 },
{ id: "p3", name: "BASIN2", score: 796 },
{ id: "p4", name: "HAZE_9", score: 764 },
{ id: "p5", name: "VOLT_5", score: 732 }
];
var nodes = {};
function human() {
if (root.classList.contains("is-demo")) root.classList.remove("is-demo");
}
// 계산 겹: 변화량(delta)의 부호로 화살표 방향을, 크기 비율로 화살표 배율을 정하고
// 지수감쇠(e^-t/tau)로 알파를 매 프레임 계산해 반짝임을 만든다 — 클수록 크고 오래 남는다.
// 같은 알파·부호를 행 배경 그라디언트·행 그림자에도 전파해(계산 겹) 반짝임이 행 전체로 보이게 한다.
function flashArrow(row, delta) {
var el = row.arrow;
var mag = Math.min(1, Math.abs(delta) / LEADERBOARD_DELTA_MAX_REF);
var scale = 0.7 + mag * 0.7;
var up = delta >= 0;
var rgb = up ? UP_RGB : DOWN_RGB;
el.classList.toggle("lbf__arrow--down", !up);
el.style.transform = "translateY(-50%) scale(" + scale.toFixed(2) + ") rotate(" + (up ? 0 : 180) + "deg)";
if (reduce) {
el.style.opacity = "0";
row.li.style.background = "";
row.li.style.boxShadow = "";
return;
}
var start = null;
el.style.opacity = "1";
function frame(now) {
if (start === null) start = now;
var t = now - start;
var alpha = Math.exp(-t / LEADERBOARD_DELTA_DECAY_TAU_MS);
if (alpha < LEADERBOARD_DELTA_FLOOR) {
el.style.opacity = "0";
row.li.style.background = "";
row.li.style.boxShadow = "";
return;
}
el.style.opacity = alpha.toFixed(3);
var a = alpha * mag;
row.li.style.background = "linear-gradient(90deg, rgba(" + rgb + "," + (a * 0.55).toFixed(3) + "), rgba(" + rgb + ",0) 78%), " + INK_HEX;
row.li.style.boxShadow = "0 " + (2 + a * 6).toFixed(1) + "px " + (10 + a * 16).toFixed(1) + "px -4px rgba(" + rgb + "," + (a * 0.5).toFixed(3) + ")";
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
function updateRow(id, delta) {
var row = nodes[id];
var p = players.filter(function (x) { return x.id === id; })[0];
row.score.textContent = String(p.score);
flashArrow(row, delta);
sr.textContent = p.name + " 점수 " + (delta >= 0 ? "+" : "") + delta + ", 현재 " + p.score;
}
function bump(id) {
var p = players.filter(function (x) { return x.id === id; })[0];
var delta = 8 + Math.floor(rand() * 30);
p.score += delta;
updateRow(id, delta);
}
players.forEach(function (p) {
var li = document.createElement("li");
li.className = "lbf__row";
li.setAttribute("role", "listitem");
li.tabIndex = 0;
var name = document.createElement("span");
name.className = "lbf__name";
name.textContent = p.name;
var score = document.createElement("span");
score.className = "lbf__score";
score.textContent = String(p.score);
var arrow = document.createElement("span");
arrow.className = "lbf__arrow";
arrow.setAttribute("aria-hidden", "true");
li.appendChild(name);
li.appendChild(score);
li.appendChild(arrow);
nodes[p.id] = { li: li, score: score, arrow: arrow };
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);
});
function scheduledBump(id) {
var p = players.filter(function (x) { return x.id === id; })[0];
var down = rand() < 0.35;
var delta = (10 + Math.floor(rand() * 30)) * (down ? -1 : 1);
p.score = Math.max(0, p.score + delta);
updateRow(id, delta);
}
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 "./LeaderboardDeltaArrowFlash.module.scss";
type Box<T> = { current: T };
const LEADERBOARD_DELTA_DECAY_TAU_MS = 260;
const LEADERBOARD_DELTA_FLOOR = 0.03;
const LEADERBOARD_DELTA_MAX_REF = 40;
// 시연 스케줄: 2초 구간 안에서 여러 행의 순위 변동이 겹쳐 일어나게 하는 고정 시각표.
// rAF 타임스탬프 차이(dt)만 쓴다 — new Date·Date.now·Math.random 없음.
const DEMO_SCHEDULE: { t: number; id: string }[] = [
{ t: 0, id: "p2" }, { t: 220, id: "p4" }, { t: 460, id: "p1" }, { t: 740, id: "p3" },
{ t: 1000, id: "p5" }, { t: 1280, id: "p2" }, { t: 1560, id: "p4" }, { t: 1840, id: "p1" }
];
const DEMO_LOOP_MS = 2000;
const UP_RGB = "91,140,255"; // $subject-blue-light 와 맞춘다
const DOWN_RGB = "194,54,26"; // $error
const INK_HEX = "#17141a"; // $subject-ink
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; score: HTMLSpanElement; arrow: HTMLSpanElement };
export default function LeaderboardDeltaArrowFlash() {
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: "ONYX_4", score: 860 },
{ id: "p2", name: "LUMEN7", score: 828 },
{ id: "p3", name: "BASIN2", score: 796 },
{ id: "p4", name: "HAZE_9", score: 764 },
{ id: "p5", name: "VOLT_5", score: 732 }
];
const nodes: Record<string, RowNodes> = {};
let raf: number | undefined;
function human() {
if (root.classList.contains("is-demo")) root.classList.remove("is-demo");
}
function flashArrow(row: RowNodes, delta: number) {
const el = row.arrow;
const mag = Math.min(1, Math.abs(delta) / LEADERBOARD_DELTA_MAX_REF);
const scale = 0.7 + mag * 0.7;
const up = delta >= 0;
const rgb = up ? UP_RGB : DOWN_RGB;
el.classList.toggle(styles["lbf__arrow--down"], !up);
el.style.transform = "translateY(-50%) scale(" + scale.toFixed(2) + ") rotate(" + (up ? 0 : 180) + "deg)";
if (reduce) {
el.style.opacity = "0";
row.li.style.background = "";
row.li.style.boxShadow = "";
return;
}
let start: number | null = null;
el.style.opacity = "1";
function frame(now: number) {
if (start === null) start = now;
const t = now - start;
const alpha = Math.exp(-t / LEADERBOARD_DELTA_DECAY_TAU_MS);
if (alpha < LEADERBOARD_DELTA_FLOOR) {
el.style.opacity = "0";
row.li.style.background = "";
row.li.style.boxShadow = "";
return;
}
el.style.opacity = alpha.toFixed(3);
const a = alpha * mag;
row.li.style.background = "linear-gradient(90deg, rgba(" + rgb + "," + (a * 0.55).toFixed(3) + "), rgba(" + rgb + ",0) 78%), " + INK_HEX;
row.li.style.boxShadow = "0 " + (2 + a * 6).toFixed(1) + "px " + (10 + a * 16).toFixed(1) + "px -4px rgba(" + rgb + "," + (a * 0.5).toFixed(3) + ")";
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
function updateRow(id: string, delta: number) {
const row = nodes[id];
const p = players.filter((x) => x.id === id)[0];
row.score.textContent = String(p.score);
flashArrow(row, delta);
sr.textContent = p.name + " 점수 " + (delta >= 0 ? "+" : "") + delta + ", 현재 " + p.score;
}
function bump(id: string) {
const p = players.filter((x) => x.id === id)[0];
const delta = 8 + Math.floor(rand() * 30);
p.score += delta;
updateRow(id, delta);
}
players.forEach((p) => {
const li = document.createElement("li");
li.className = styles.lbf__row;
li.setAttribute("role", "listitem");
li.tabIndex = 0;
const name = document.createElement("span");
name.className = styles.lbf__name;
name.textContent = p.name;
const score = document.createElement("span");
score.className = styles.lbf__score;
score.textContent = String(p.score);
const arrow = document.createElement("span");
arrow.className = styles.lbf__arrow;
arrow.setAttribute("aria-hidden", "true");
li.appendChild(name);
li.appendChild(score);
li.appendChild(arrow);
nodes[p.id] = { li, score, arrow };
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);
});
function scheduledBump(id: string) {
const p = players.filter((x) => x.id === id)[0];
const down = rand() < 0.35;
const delta = (10 + Math.floor(rand() * 30)) * (down ? -1 : 1);
p.score = Math.max(0, p.score + delta);
updateRow(id, delta);
}
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.lbf + " is-demo"} ref={rootRef}>
<ul className={styles.lbf__list} ref={listRef} role="list" />
<p className={styles.lbf__sr} ref={srRef} aria-live="polite" />
</div>
</main>
);
}07 오르내림을 알려주는 화살표 배지 — 측정 누적면적 31.854% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 랭킹 ui 디자인 9가지 — 랭킹 페이지 디자인도 같은 원리다
이 데모가 속한 글
랭킹 ui 디자인 9가지 — 랭킹 페이지 디자인도 같은 원리다랭킹 ui 디자인은 순위가 바뀌는 순간을 어떻게 보여주느냐의 문제다. 달리기 선수들이 결승선 앞에서 자리를 바꾸는 순간처럼, 순위표도 매번 다른 방식으로 흔들린다 — 01번은 두 행이 서로의 자리를 타고 넘듯 자리를 이 글의 데모 전부 보기


