01Leaderboard Rows That FLIP-Swap Rank Positions
When ranks change, the two rows glide from their old spot to the new one, passing through each other's position.
Best forReal-time game leaderboard
LEADERBOARD_FLIP_DURATION_MSmeasureRectsflipSwap

<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 *;
// 계산 겹: bump()/자동 루프가 flipSwap() 을 호출하면 measureRects() 로 잰 이전 위치와
// 재정렬 뒤 새 위치의 차이를 translateY 오프셋으로 역보정(FLIP 의 Invert)한 다음
// 프레임에 0 으로 되돌려(Play), 두 행이 서로의 자리를 타고 넘듯 이동하는 것처럼 보인다.
.stage { @include stage($stage-ink); }
$duration: $dur-enter;
$easing: $ease-spring;
$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;
position: relative;
}
.lbf__row {
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba($subject-cream, .08);
cursor: pointer;
}
.lbf__row:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
.lbf__rank {
width: 22px;
flex: 0 0 auto;
font-size: 13px;
font-weight: 700;
color: $subject-blue-light;
text-align: center;
}
.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__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; gap: $sp-2; }
.lbf__name { font-size: 11px; }
.lbf__score { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.lbf__row { 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: #17141a;
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;
position: relative;
}
.lbf__row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.08);
cursor: pointer;
}
.lbf__row:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.lbf__rank {
width: 22px;
flex: 0 0 auto;
font-size: 13px;
font-weight: 700;
color: #5b8cf8;
text-align: center;
}
.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__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;
gap: 8px;
}
.lbf__name {
font-size: 11px;
}
.lbf__score {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.lbf__row {
transition: none !important;
}
}(function () {
"use strict";
var LEADERBOARD_FLIP_DURATION_MS = 600;
var LEADERBOARD_FLIP_SWAP_MS = 460;
var LEADERBOARD_FLIP_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("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: "NOVA_7", score: 980 },
{ id: "p2", name: "ECHO_K", score: 940 },
{ id: "p3", name: "RAPTOR9", score: 900 },
{ id: "p4", name: "GLIDEX", score: 860 },
{ id: "p5", name: "ZEN_44", score: 820 }
];
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 measureRects() {
var r = {};
players.forEach(function (p) { r[p.id] = nodes[p.id].li.getBoundingClientRect(); });
return r;
}
// 계산 겹: 이전 rect 와 재정렬 뒤 새 rect 의 차이를 translateY 오프셋으로 역보정(FLIP 의 Invert)한
// 다음 다음 프레임에 transform 을 0 으로 되돌려(Play) 두 행이 서로의 자리를 타고 넘듯 이동한다.
function flipSwap(before) {
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);
});
if (reduce) return;
order.forEach(function (p) {
var li = nodes[p.id].li;
var after = li.getBoundingClientRect();
var dy = before[p.id].top - after.top;
if (Math.abs(dy) < 0.5) return;
animateFlipRow(nodes[p.id], dy);
});
}
// 계산 겹: 이동 진행률(hump = 0→1→0)을 그림자 크기·배경 톤에 실어 Web Animations API
// 키프레임으로 한 번에 재생한다 — CSS transition 대신 el.animate() 를 쓰는 이유는
// document.getAnimations() 로 잡혀야 demo.py 가 애니메이션 시계를 프레임마다 정확히
// 놓아 결정론적으로 캡처하기 때문이다(수동 rAF 폴링은 실시간이라 매 실행마다 흔들린다).
// 뒤늦게 같은 행이 다시 트리거되면 이전 재생을 취소하고 새 애니메이션으로 넘어간다.
function animateFlipRow(node, dy) {
var li = node.li;
if (node.anim) node.anim.cancel();
var baseShadow = "0 2px 10px rgba(10,16,32,0)";
var peakShadow = "0 " + (2 + 12).toFixed(1) + "px " + (10 + 20).toFixed(1) + "px rgba(10,16,32,.28)";
var baseBg = "rgba(" + LEADERBOARD_FLIP_HIGHLIGHT + ",0.08)";
var peakBg = "rgba(" + LEADERBOARD_FLIP_HIGHLIGHT + ",0.32)";
node.anim = li.animate([
{ transform: "translateY(" + dy + "px)", boxShadow: baseShadow, background: baseBg, offset: 0 },
{ boxShadow: peakShadow, background: peakBg, offset: 0.5 },
{ transform: "translateY(0px)", boxShadow: baseShadow, background: baseBg, offset: 1 }
], { duration: LEADERBOARD_FLIP_DURATION_MS, easing: "cubic-bezier(.2,.8,.2,1)", fill: "forwards" });
node.anim.onfinish = function () {
li.style.transform = "";
li.style.boxShadow = "";
li.style.background = "";
};
}
function bump(id) {
var before = measureRects();
var p = players.filter(function (x) { return x.id === id; })[0];
var oldRank = sortedPlayers().indexOf(p);
p.score += 40 + Math.floor(rand() * 60);
flipSwap(before);
var newRank = sortedPlayers().indexOf(p);
if (newRank < oldRank) sr.textContent = p.name + " moved up to rank " + (newRank + 1) + " now";
}
// 한 틱에 서로 다른 두 참가자를 동시에 밀어올려 여러 행이 한 번에 FLIP 하게 한다.
function bumpMany() {
var before = measureRects();
var pool = players.slice();
var picks = [];
for (var n = 0; n < 2 && pool.length; n++) {
var i = Math.floor(rand() * pool.length);
picks.push(pool.splice(i, 1)[0]);
}
picks.forEach(function (p) { p.score += 35 + Math.floor(rand() * 55); });
flipSwap(before);
}
players.forEach(function (p) {
var li = document.createElement("li");
li.className = "lbf__row";
li.setAttribute("role", "listitem");
li.tabIndex = 0;
var rank = document.createElement("span");
rank.className = "lbf__rank";
var name = document.createElement("span");
name.className = "lbf__name";
name.textContent = p.name;
var score = document.createElement("span");
score.className = "lbf__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); }
});
});
sortedPlayers().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);
});
if (!reduce) {
(function loop() {
if (root.classList.contains("is-demo")) {
bumpMany();
}
window.setTimeout(loop, LEADERBOARD_FLIP_SWAP_MS);
})();
}
})();import { useEffect, useRef } from "react";
import styles from "./LeaderboardFlipRankSwap.module.scss";
type Box<T> = { current: T };
const LEADERBOARD_FLIP_DURATION_MS = 600;
const LEADERBOARD_FLIP_SWAP_MS = 460;
const LEADERBOARD_FLIP_HIGHLIGHT = "91,140,248";
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; anim?: Animation };
export default function LeaderboardFlipRankSwap() {
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: "NOVA_7", score: 980 },
{ id: "p2", name: "ECHO_K", score: 940 },
{ id: "p3", name: "RAPTOR9", score: 900 },
{ id: "p4", name: "GLIDEX", score: 860 },
{ id: "p5", name: "ZEN_44", score: 820 }
];
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 measureRects() {
const map: Record<string, DOMRect> = {};
players.forEach((p) => {
map[p.id] = nodes[p.id].li.getBoundingClientRect();
});
return map;
}
function flipSwap(before: Record<string, DOMRect>) {
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);
});
if (reduce) return;
players.forEach((p) => {
const node = nodes[p.id];
const el = node.li;
const after = el.getBoundingClientRect();
const dy = before[p.id].top - after.top;
if (Math.abs(dy) < 0.5) return;
animateFlipRow(node, dy);
});
sr.textContent = order[0].name + " 이(가) 1위입니다";
}
// 계산 겹: 이동 진행률(hump = 0→1→0)을 그림자·배경 톤에 실어 Web Animations API
// 키프레임으로 재생한다 — document.getAnimations() 로 잡혀야 demo.py 가 애니메이션
// 시계를 프레임마다 정확히 놓아 결정론적으로 캡처한다.
function animateFlipRow(node: RowNodes, dy: number) {
const el = node.li;
if (node.anim) node.anim.cancel();
const baseShadow = "0 2px 10px rgba(10,16,32,0)";
const peakShadow = "0 14px 30px rgba(10,16,32,.28)";
const baseBg = "rgba(" + LEADERBOARD_FLIP_HIGHLIGHT + ",0.08)";
const peakBg = "rgba(" + LEADERBOARD_FLIP_HIGHLIGHT + ",0.32)";
node.anim = el.animate([
{ transform: "translateY(" + dy + "px)", boxShadow: baseShadow, background: baseBg, offset: 0 },
{ boxShadow: peakShadow, background: peakBg, offset: 0.5 },
{ transform: "translateY(0px)", boxShadow: baseShadow, background: baseBg, offset: 1 }
], { duration: LEADERBOARD_FLIP_DURATION_MS, easing: "cubic-bezier(.2,.8,.2,1)", fill: "forwards" });
node.anim.onfinish = () => {
el.style.transform = "";
el.style.boxShadow = "";
el.style.background = "";
};
}
function bump(id: string) {
const before = measureRects();
const p = players.filter((x) => x.id === id)[0];
p.score += 40 + Math.floor(rand() * 60);
flipSwap(before);
}
// 한 틱에 서로 다른 두 참가자를 동시에 밀어올려 여러 행이 한 번에 FLIP 하게 한다.
function bumpMany() {
const before = measureRects();
const pool = players.slice();
const picks: Player[] = [];
for (let n = 0; n < 2 && pool.length; n++) {
const i = Math.floor(rand() * pool.length);
picks.push(pool.splice(i, 1)[0]);
}
picks.forEach((p) => { p.score += 35 + Math.floor(rand() * 55); });
flipSwap(before);
}
players.forEach((p) => {
const li = document.createElement("li");
li.className = styles.lbf__row;
li.setAttribute("role", "listitem");
li.tabIndex = 0;
const rank = document.createElement("span");
rank.className = styles.lbf__rank;
const name = document.createElement("span");
name.className = styles.lbf__name;
name.textContent = p.name;
const score = document.createElement("span");
score.className = styles.lbf__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);
});
flipSwap(measureRects());
if (!reduce) {
const loop = () => {
if (root.classList.contains("is-demo")) {
bumpMany();
}
timer = window.setTimeout(loop, LEADERBOARD_FLIP_SWAP_MS);
};
timer = window.setTimeout(loop, LEADERBOARD_FLIP_SWAP_MS);
}
return () => {
if (timer) window.clearTimeout(timer);
};
}, []);
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>
);
}01 Leaderboard Rows That FLIP-Swap Rank Positions — Measured area 33.704% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · Leaderboard UI Design: 9 Rank-Change List Patterns
The post this demo lives in
Leaderboard UI Design: 9 Rank-Change List PatternsThis leaderboard ui design is built on one feel: A leaderboard shifts like runners trading places at the line. Row 01 gl See all demos in this post
If you like this effect - similar demos
Lists & tables03 Pull-to-Refresh That Sweeps Only the Rows That ChangedInfinite Scroll Implementation — 9 List UI Basics
Lists & tables03 Sort Transition That Slides by a Calculated Distance From Its Old PositionComment Section UI: 9 Thread List Patterns
Lists & tables07 Skeleton shimmer rows9 Swipe to Delete List UI Patterns (Copy-Paste)