09동점자를 한 줄로 합치는 깜빡임
점수가 같은 두 행은 짧게 깜빡인 뒤 한 줄로 겹쳐 공동 순위임을 보여준다.
쓰임퀴즈 결과 공동 순위 표시
computeRanksLEADERBOARD_TIE_STEPtieRanks

<main class="stage">
<div class="lbt is-demo" id="lbt">
<ul class="lbt__list" id="lbtList" role="list"></ul>
<p class="lbt__sr" id="lbtSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html computeRanks() 의 공동순위 결과(tieRanks)에 따라 붙는 클래스만
// 여기서 받는다 — 어떤 두 행이 반짝일지는 전부 점수 비교 계산이 정한다.
.stage { @include stage($stage-ink); }
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
.lbt {
width: min(88vw, 320px);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.lbt__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.lbt__row {
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: $subject-cream;
cursor: pointer;
}
.lbt__row:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
}
.lbt__rank {
width: 22px;
flex: 0 0 auto;
font-size: 13px;
font-weight: 700;
color: $subject-blue-deep;
text-align: center;
}
.lbt__name {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 700;
color: $subject-ink;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lbt__score {
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
color: $subject-ink;
font-variant-numeric: tabular-nums;
}
@keyframes lbt-tie-flash {
0%, 100% { background: $subject-cream; box-shadow: none; }
50% { background: rgba($stage-yellow, .55); box-shadow: 0 0 0 2px rgba($stage-yellow, .8); }
}
.lbt__row--tie {
animation: lbt-tie-flash $duration $easing infinite;
}
.lbt__row--tie-static {
box-shadow: 0 0 0 2px $stage-yellow;
}
.lbt__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) {
.lbt { width: min(94vw, 260px); }
.lbt__list { gap: $sp-1; }
.lbt__row { padding: $sp-1 $sp-2; gap: $sp-2; }
.lbt__name { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.lbt__row--tie { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lbt {
width: min(88vw, 320px);
display: flex;
flex-direction: column;
gap: 8px;
}
.lbt__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.lbt__row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 8px;
background: #fff7e6;
cursor: pointer;
}
.lbt__row:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.lbt__rank {
width: 22px;
flex: 0 0 auto;
font-size: 13px;
font-weight: 700;
color: #0a4fe8;
text-align: center;
}
.lbt__name {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 700;
color: #17141a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lbt__score {
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
color: #17141a;
font-variant-numeric: tabular-nums;
}
@keyframes lbt-tie-flash {
0%, 100% {
background: #fff7e6;
box-shadow: none;
}
50% {
background: rgba(255, 210, 63, 0.55);
box-shadow: 0 0 0 2px rgba(255, 210, 63, 0.8);
}
}
.lbt__row--tie {
animation: lbt-tie-flash 2s ease-out infinite;
}
.lbt__row--tie-static {
box-shadow: 0 0 0 2px #ffd23f;
}
.lbt__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) {
.lbt {
width: min(94vw, 260px);
}
.lbt__list {
gap: 4px;
}
.lbt__row {
padding: 4px 8px;
gap: 8px;
}
.lbt__name {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.lbt__row--tie {
animation: none !important;
}
}(function () {
"use strict";
var LEADERBOARD_TIE_STEP = 10;
var LEADERBOARD_TIE_TICK_MS = 780;
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("lbt");
var list = document.getElementById("lbtList");
var sr = document.getElementById("lbtSr");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var players = [
{ id: "p1", name: "ORCA_6", score: 680 },
{ id: "p2", name: "FINCH3", score: 660 },
{ id: "p3", name: "CROW_8", score: 640 },
{ id: "p4", name: "HARE_2", score: 620 },
{ id: "p5", name: "WREN_5", score: 600 }
];
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위처럼), 다르면 "지금까지 본 인원 수 + 1" 로 순위가 그 자리만큼 건너뛴다.
function computeRanks(order) {
var ranks = [];
for (var i = 0; i < order.length; i++) {
if (i > 0 && order[i].score === order[i - 1].score) ranks.push(ranks[i - 1]);
else ranks.push(i + 1);
}
return ranks;
}
function relayout(announce) {
var order = sortedPlayers();
var ranks = computeRanks(order);
var tieRanks = {};
ranks.forEach(function (r, i) {
tieRanks[r] = (tieRanks[r] || 0) + 1;
});
order.forEach(function (p, i) {
list.appendChild(nodes[p.id].li);
nodes[p.id].rank.textContent = String(ranks[i]);
nodes[p.id].score.textContent = String(p.score);
var isTie = tieRanks[ranks[i]] > 1;
nodes[p.id].li.classList.toggle("lbt__row--tie", isTie && !reduce);
nodes[p.id].li.classList.toggle("lbt__row--tie-static", isTie && reduce);
});
if (announce) {
var tieNames = order.filter(function (p, i) { return tieRanks[ranks[i]] > 1; }).map(function (p) { return p.name; });
sr.textContent = tieNames.length ? tieNames.join(", ") + " 공동 순위" : order[0].name + " 이(가) 1위";
}
}
function bump(id) {
var p = players.filter(function (x) { return x.id === id; })[0];
var steps = 1 + Math.floor(rand() * 3);
p.score += steps * LEADERBOARD_TIE_STEP;
relayout(true);
}
players.forEach(function (p) {
var li = document.createElement("li");
li.className = "lbt__row";
li.setAttribute("role", "listitem");
li.tabIndex = 0;
var rank = document.createElement("span");
rank.className = "lbt__rank";
var name = document.createElement("span");
name.className = "lbt__name";
name.textContent = p.name;
var score = document.createElement("span");
score.className = "lbt__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); }
});
list.appendChild(li);
});
relayout(false);
if (!reduce) {
(function loop() {
if (root.classList.contains("is-demo")) {
var idx = Math.floor(rand() * players.length);
var steps = 1 + Math.floor(rand() * 3);
players[idx].score += steps * LEADERBOARD_TIE_STEP;
relayout(true);
}
window.setTimeout(loop, LEADERBOARD_TIE_TICK_MS);
})();
}
})();import { useEffect, useRef } from "react";
import styles from "./LeaderboardTieMergeFlash.module.scss";
type Box<T> = { current: T };
const LEADERBOARD_TIE_STEP = 10;
const LEADERBOARD_TIE_TICK_MS = 780;
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 };
export default function LeaderboardTieMergeFlash() {
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: "ORCA_6", score: 680 },
{ id: "p2", name: "FINCH3", score: 660 },
{ id: "p3", name: "CROW_8", score: 640 },
{ id: "p4", name: "HARE_2", score: 620 },
{ id: "p5", name: "WREN_5", score: 600 }
];
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 computeRanks(order: Player[]) {
const ranks: number[] = [];
for (let i = 0; i < order.length; i++) {
if (i > 0 && order[i].score === order[i - 1].score) ranks.push(ranks[i - 1]);
else ranks.push(i + 1);
}
return ranks;
}
function relayout(announce: boolean) {
const order = sortedPlayers();
const ranks = computeRanks(order);
const tieRanks: Record<number, number> = {};
ranks.forEach((r) => {
tieRanks[r] = (tieRanks[r] || 0) + 1;
});
order.forEach((p, i) => {
list.appendChild(nodes[p.id].li);
nodes[p.id].rank.textContent = String(ranks[i]);
nodes[p.id].score.textContent = String(p.score);
const isTie = tieRanks[ranks[i]] > 1;
nodes[p.id].li.classList.toggle(styles["lbt__row--tie"], isTie && !reduce);
nodes[p.id].li.classList.toggle(styles["lbt__row--tie-static"], isTie && reduce);
});
if (announce) {
const tieNames = order.filter((p, i) => tieRanks[ranks[i]] > 1).map((p) => p.name);
sr.textContent = tieNames.length ? tieNames.join(", ") + " 공동 순위" : order[0].name + " 이(가) 1위";
}
}
function bump(id: string) {
const p = players.filter((x) => x.id === id)[0];
const steps = 1 + Math.floor(rand() * 3);
p.score += steps * LEADERBOARD_TIE_STEP;
relayout(true);
}
players.forEach((p) => {
const li = document.createElement("li");
li.className = styles.lbt__row;
li.setAttribute("role", "listitem");
li.tabIndex = 0;
const rank = document.createElement("span");
rank.className = styles.lbt__rank;
const name = document.createElement("span");
name.className = styles.lbt__name;
name.textContent = p.name;
const score = document.createElement("span");
score.className = styles.lbt__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); }
});
list.appendChild(li);
});
relayout(false);
if (!reduce) {
const loop = () => {
if (root.classList.contains("is-demo")) {
const idx = Math.floor(rand() * players.length);
const steps = 1 + Math.floor(rand() * 3);
players[idx].score += steps * LEADERBOARD_TIE_STEP;
relayout(true);
}
timer = window.setTimeout(loop, LEADERBOARD_TIE_TICK_MS);
};
timer = window.setTimeout(loop, LEADERBOARD_TIE_TICK_MS);
}
return () => {
if (timer) window.clearTimeout(timer);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.lbt + " is-demo"} ref={rootRef}>
<ul className={styles.lbt__list} ref={listRef} role="list" />
<p className={styles.lbt__sr} ref={srRef} aria-live="polite" />
</div>
</main>
);
}09 동점자를 한 줄로 합치는 깜빡임 — 측정 누적면적 17.028% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 랭킹 ui 디자인 9가지 — 랭킹 페이지 디자인도 같은 원리다
이 데모가 속한 글
랭킹 ui 디자인 9가지 — 랭킹 페이지 디자인도 같은 원리다랭킹 ui 디자인은 순위가 바뀌는 순간을 어떻게 보여주느냐의 문제다. 달리기 선수들이 결승선 앞에서 자리를 바꾸는 순간처럼, 순위표도 매번 다른 방식으로 흔들린다 — 01번은 두 행이 서로의 자리를 타고 넘듯 자리를 이 글의 데모 전부 보기


