06Ranking List That Keeps Gliding After You Let Go
Releasing the drag doesn't stop the list — it keeps coasting at that speed, then slows to a stop from friction.
Best forMobile ranking-list scroll
LEADERBOARD_SCROLL_FRICTIONcoastStepLEADERBOARD_SCROLL_MIN_VELOCITY

<main class="stage">
<div class="lbi is-demo" id="lbi">
<div class="lbi__viewport" id="lbiViewport" tabindex="0" role="list" aria-label="Ranking list, scroll with arrow keys">
<ul class="lbi__track" id="lbiTrack"></ul>
</div>
<p class="lbi__sr" id="lbiSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html coastStep() 이 LEADERBOARD_SCROLL_FRICTION 으로 속도를 매 프레임
// 감쇠시켜 만든 offset(px) 값을 여기 translateY 가 그대로 받는다 — 레이아웃(overflow)은 안 건드린다.
.stage { @include stage($stage-yellow); }
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-cream;
.lbi {
width: min(88vw, 320px);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.lbi__viewport {
height: 200px;
overflow: hidden;
border-radius: $r-sm;
background: $subject-ink;
touch-action: none;
cursor: grab;
}
.lbi__viewport:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
.lbi__track {
list-style: none;
margin: 0;
padding: 0;
will-change: transform;
}
.lbi__row {
height: 40px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-3;
padding: 0 $sp-3;
border-bottom: 1px solid rgba($subject-cream, .08);
}
.lbi__rank {
width: 20px;
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
color: $subject-blue-light;
text-align: center;
}
.lbi__name {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 700;
color: $color;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lbi__score {
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
color: $color;
font-variant-numeric: tabular-nums;
}
.lbi__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) {
.lbi { width: min(94vw, 260px); }
.lbi__viewport { height: 140px; }
.lbi__row { padding: 0 $sp-2; gap: $sp-2; }
.lbi__name { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.lbi__track { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lbi {
width: min(88vw, 320px);
display: flex;
flex-direction: column;
gap: 8px;
}
.lbi__viewport {
height: 200px;
overflow: hidden;
border-radius: 8px;
background: #17141a;
touch-action: none;
cursor: grab;
}
.lbi__viewport:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.lbi__track {
list-style: none;
margin: 0;
padding: 0;
will-change: transform;
}
.lbi__row {
height: 40px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
padding: 0 12px;
border-bottom: 1px solid rgba(255, 247, 230, 0.08);
}
.lbi__rank {
width: 20px;
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
color: #5b8cf8;
text-align: center;
}
.lbi__name {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 700;
color: #fff7e6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lbi__score {
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.lbi__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) {
.lbi {
width: min(94vw, 260px);
}
.lbi__viewport {
height: 140px;
}
.lbi__row {
padding: 0 8px;
gap: 8px;
}
.lbi__name {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.lbi__track {
transition: none !important;
}
}(function () {
"use strict";
var LEADERBOARD_SCROLL_FRICTION = 0.92;
var LEADERBOARD_SCROLL_MIN_VELOCITY = 0.04;
var LEADERBOARD_SCROLL_ROW_H = 40;
var LEADERBOARD_SCROLL_STEP_PX = 40;
var LEADERBOARD_SCROLL_AUTO_TICK_MS = 1500;
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("lbi");
var viewport = document.getElementById("lbiViewport");
var track = document.getElementById("lbiTrack");
var sr = document.getElementById("lbiSr");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var players = [
{ name: "CEDAR6", score: 988 }, { name: "IVORY3", score: 951 },
{ name: "PLUME9", score: 914 }, { name: "QUARTZ1", score: 877 },
{ name: "BRISK4", score: 840 }, { name: "MOSSY7", score: 803 },
{ name: "EMBER2", score: 766 }, { name: "SLATE8", score: 729 },
{ name: "GROVE5", score: 692 }, { name: "TIDE_0", score: 655 },
{ name: "FERN_9", score: 618 }, { name: "DUSK_3", score: 581 }
];
var maxScroll = Math.max(0, players.length * LEADERBOARD_SCROLL_ROW_H - viewport.clientHeight);
var offset = 0;
var velocity = 0;
var coasting = false;
var dragging = false;
var dragStartY = 0, dragStartOffset = 0, lastY = 0, lastT = 0;
function human() {
if (root.classList.contains("is-demo")) root.classList.remove("is-demo");
}
function clamp(v) { return Math.max(0, Math.min(maxScroll, v)); }
function render() {
track.style.transform = "translateY(-" + offset.toFixed(1) + "px)";
}
function announceTop() {
var idx = Math.round(offset / LEADERBOARD_SCROLL_ROW_H);
var p = players[Math.max(0, Math.min(players.length - 1, idx))];
sr.textContent = p.name + " shown at top";
}
// 계산 겹: 놓는 순간의 속도(velocity)를 매 프레임 LEADERBOARD_SCROLL_FRICTION 만큼 감쇠시켜
// 목록이 미끄러지다 멎는다 — 마찰이 낮을수록 더 오래, 높을수록 더 빨리 멈춘다.
function coastStep() {
if (!coasting) return;
offset += velocity;
velocity *= LEADERBOARD_SCROLL_FRICTION;
if (offset < 0) { offset = 0; velocity = 0; }
if (offset > maxScroll) { offset = maxScroll; velocity = 0; }
render();
if (Math.abs(velocity) < LEADERBOARD_SCROLL_MIN_VELOCITY || (offset === 0 || offset === maxScroll)) {
coasting = false;
announceTop();
return;
}
requestAnimationFrame(coastStep);
}
function startCoast(v) {
if (reduce) {
offset = clamp(offset + v * 6);
render();
announceTop();
return;
}
velocity = v;
coasting = true;
requestAnimationFrame(coastStep);
}
function onPointerDown(e) {
human();
dragging = true;
coasting = false;
dragStartY = e.clientY;
dragStartOffset = offset;
lastY = e.clientY;
lastT = e.timeStamp;
viewport.setPointerCapture(e.pointerId);
}
function onPointerMove(e) {
if (!dragging) return;
var dy = dragStartY - e.clientY;
offset = clamp(dragStartOffset + dy);
var dt = Math.max(1, e.timeStamp - lastT);
velocity = (lastY - e.clientY) / dt * 16;
lastY = e.clientY;
lastT = e.timeStamp;
render();
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
startCoast(velocity);
}
function onWheel(e) {
human();
e.preventDefault();
offset = clamp(offset + e.deltaY * 0.5);
render();
announceTop();
}
function onKeydown(e) {
if (e.key === "ArrowDown") { human(); e.preventDefault(); offset = clamp(offset + LEADERBOARD_SCROLL_STEP_PX); render(); announceTop(); }
else if (e.key === "ArrowUp") { human(); e.preventDefault(); offset = clamp(offset - LEADERBOARD_SCROLL_STEP_PX); render(); announceTop(); }
else if (e.key === "PageDown") { human(); e.preventDefault(); offset = clamp(offset + viewport.clientHeight); render(); announceTop(); }
else if (e.key === "PageUp") { human(); e.preventDefault(); offset = clamp(offset - viewport.clientHeight); render(); announceTop(); }
}
players.forEach(function (p, i) {
var li = document.createElement("li");
li.className = "lbi__row";
li.setAttribute("role", "listitem");
var rank = document.createElement("span");
rank.className = "lbi__rank";
rank.textContent = String(i + 1);
var name = document.createElement("span");
name.className = "lbi__name";
name.textContent = p.name;
var score = document.createElement("span");
score.className = "lbi__score";
score.textContent = String(p.score);
li.appendChild(rank);
li.appendChild(name);
li.appendChild(score);
track.appendChild(li);
});
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("pointercancel", onPointerUp);
viewport.addEventListener("wheel", onWheel, { passive: false });
viewport.addEventListener("keydown", onKeydown);
render();
if (!reduce) {
(function loop() {
if (root.classList.contains("is-demo") && !dragging && !coasting) {
var dir = rand() > 0.5 ? 1 : -1;
var kick = (6 + rand() * 5) * dir;
startCoast(kick);
}
window.setTimeout(loop, LEADERBOARD_SCROLL_AUTO_TICK_MS);
})();
}
})();import { useEffect, useRef } from "react";
import styles from "./LeaderboardInertiaScrollList.module.scss";
type Box<T> = { current: T };
const LEADERBOARD_SCROLL_FRICTION = 0.92;
const LEADERBOARD_SCROLL_MIN_VELOCITY = 0.04;
const LEADERBOARD_SCROLL_ROW_H = 40;
const LEADERBOARD_SCROLL_STEP_PX = 40;
const LEADERBOARD_SCROLL_AUTO_TICK_MS = 1500;
function seedRand(seed: number) {
let s = seed % 2147483647;
if (s <= 0) s += 2147483646;
return function () {
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
}
type PlayerRow = { name: string; score: number };
export default function LeaderboardInertiaScrollList() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const viewportRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLUListElement>;
const srRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
useEffect(() => {
const root = rootRef.current;
const viewport = viewportRef.current;
const track = trackRef.current;
const sr = srRef.current;
const rand = seedRand(418);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const players: PlayerRow[] = [
{ name: "CEDAR6", score: 988 }, { name: "IVORY3", score: 951 },
{ name: "PLUME9", score: 914 }, { name: "QUARTZ1", score: 877 },
{ name: "BRISK4", score: 840 }, { name: "MOSSY7", score: 803 },
{ name: "EMBER2", score: 766 }, { name: "SLATE8", score: 729 },
{ name: "GROVE5", score: 692 }, { name: "TIDE_0", score: 655 },
{ name: "FERN_9", score: 618 }, { name: "DUSK_3", score: 581 }
];
const maxScroll = Math.max(0, players.length * LEADERBOARD_SCROLL_ROW_H - viewport.clientHeight);
let offset = 0;
let velocity = 0;
let coasting = false;
let dragging = false;
let dragStartY = 0, dragStartOffset = 0, lastY = 0, lastT = 0;
let raf: number | undefined;
let timer: number | undefined;
function human() {
if (root.classList.contains("is-demo")) root.classList.remove("is-demo");
}
function clamp(v: number) {
return Math.max(0, Math.min(maxScroll, v));
}
function render() {
track.style.transform = "translateY(-" + offset.toFixed(1) + "px)";
}
function announceTop() {
const idx = Math.round(offset / LEADERBOARD_SCROLL_ROW_H);
const p = players[Math.max(0, Math.min(players.length - 1, idx))];
sr.textContent = p.name + " 부터 보임";
}
function coastStep() {
if (!coasting) return;
offset += velocity;
velocity *= LEADERBOARD_SCROLL_FRICTION;
if (offset < 0) { offset = 0; velocity = 0; }
if (offset > maxScroll) { offset = maxScroll; velocity = 0; }
render();
if (Math.abs(velocity) < LEADERBOARD_SCROLL_MIN_VELOCITY || offset === 0 || offset === maxScroll) {
coasting = false;
announceTop();
return;
}
raf = requestAnimationFrame(coastStep);
}
function startCoast(v: number) {
if (reduce) {
offset = clamp(offset + v * 6);
render();
announceTop();
return;
}
velocity = v;
coasting = true;
raf = requestAnimationFrame(coastStep);
}
function onPointerDown(e: PointerEvent) {
human();
dragging = true;
coasting = false;
dragStartY = e.clientY;
dragStartOffset = offset;
lastY = e.clientY;
lastT = e.timeStamp;
viewport.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const dy = dragStartY - e.clientY;
offset = clamp(dragStartOffset + dy);
const dt = Math.max(1, e.timeStamp - lastT);
velocity = ((lastY - e.clientY) / dt) * 16;
lastY = e.clientY;
lastT = e.timeStamp;
render();
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
startCoast(velocity);
}
function onWheel(e: WheelEvent) {
human();
e.preventDefault();
offset = clamp(offset + e.deltaY * 0.5);
render();
announceTop();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "ArrowDown") { human(); e.preventDefault(); offset = clamp(offset + LEADERBOARD_SCROLL_STEP_PX); render(); announceTop(); }
else if (e.key === "ArrowUp") { human(); e.preventDefault(); offset = clamp(offset - LEADERBOARD_SCROLL_STEP_PX); render(); announceTop(); }
else if (e.key === "PageDown") { human(); e.preventDefault(); offset = clamp(offset + viewport.clientHeight); render(); announceTop(); }
else if (e.key === "PageUp") { human(); e.preventDefault(); offset = clamp(offset - viewport.clientHeight); render(); announceTop(); }
}
players.forEach((p, i) => {
const li = document.createElement("li");
li.className = styles.lbi__row;
li.setAttribute("role", "listitem");
const rank = document.createElement("span");
rank.className = styles.lbi__rank;
rank.textContent = String(i + 1);
const name = document.createElement("span");
name.className = styles.lbi__name;
name.textContent = p.name;
const score = document.createElement("span");
score.className = styles.lbi__score;
score.textContent = String(p.score);
li.appendChild(rank);
li.appendChild(name);
li.appendChild(score);
track.appendChild(li);
});
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("pointercancel", onPointerUp);
viewport.addEventListener("wheel", onWheel, { passive: false });
viewport.addEventListener("keydown", onKeydown);
render();
if (!reduce) {
const loop = () => {
if (root.classList.contains("is-demo") && !dragging && !coasting) {
const dir = rand() > 0.5 ? 1 : -1;
const kick = (6 + rand() * 5) * dir;
startCoast(kick);
}
timer = window.setTimeout(loop, LEADERBOARD_SCROLL_AUTO_TICK_MS);
};
timer = window.setTimeout(loop, LEADERBOARD_SCROLL_AUTO_TICK_MS);
}
return () => {
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("pointercancel", onPointerUp);
viewport.removeEventListener("wheel", onWheel);
viewport.removeEventListener("keydown", onKeydown);
if (timer) window.clearTimeout(timer);
if (raf) cancelAnimationFrame(raf);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.lbi + " is-demo"} ref={rootRef}>
<div
className={styles.lbi__viewport}
ref={viewportRef}
tabIndex={0}
role="list"
aria-label="랭킹 목록, 화살표 키로 스크롤"
>
<ul className={styles.lbi__track} ref={trackRef} />
</div>
<p className={styles.lbi__sr} ref={srRef} aria-live="polite" />
</div>
</main>
);
}06 Ranking List That Keeps Gliding After You Let Go — Measured area 15.895% · moving frames 10/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


