GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

랭킹 ui 디자인 9가지 — 랭킹 페이지 디자인도 같은 원리다

랭킹 ui 디자인은 순위가 바뀌는 순간을 어떻게 보여주느냐의 문제다. 달리기 선수들이 결승선 앞에서 자리를 바꾸는 순간처럼, 순위표도 매번 다른 방식으로 흔들린다 — 01번은 두 행이 서로의 자리를 타고 넘듯 자리를 맞바꾸고, 05번은 1등 칸에만 금빛 빛줄기가 지나가며, 08번은

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 반응하는 시점이 저마다 다르다. 01·02·05·07·09는 순위나 점수가 바뀌는 그 순간에만 계산이 일어나고, 03은 점수 숫자가 오르는 동안, 04·08은 정렬이 끝난 뒤 화면에 떠 있는 내내, 06은 손을 뗀 다음부터 반응한다.

01순위가 바뀔 때 두 행이 서로의 자리를 타고 넘는 FLIP 교체

두 선수의 순위가 뒤집히면 두 행이 순간이동하지 않고 이전 자리에서 새 자리까지 서로를 스치듯 지나간다. measureRects로 재정렬 전 getBoundingClientRect 위치를 재둔 뒤, 재정렬 후 위치와의 차이(dy)만큼 역보정해 시작점을 만들고 그 값을 0으로 되돌리며 재생하는 FLIP 계산이다.

카드가 서로를 타고 넘는 FLIP 순위 교체
새 창에서 열기

01 · 카드가 서로를 타고 넘는 FLIP 순위 교체 — 측정 누적면적 33.704% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LEADERBOARD_FLIP_DURATION_MSmeasureRectsflipSwap
  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);
    });
  }

01 항목은 누적면적 33.7%·강도 평균 29.9·23프레임 중 7프레임에서 움직였다.

02순위가 오르면 스프링 감쇠식으로 튀어 올랐다 가라앉는 행

한 칸이라도 순위가 오르면 그 행이 잠깐 커졌다가 스프링처럼 튕기며 제자리 크기로 줄어든다. springScale이 목표 오버슛 비율을 제어이론의 2차계 스텝응답 공식으로 감쇠비(zeta)로 역산해, LEADERBOARD_RANKUP_OVERSHOOT_PERCENT 값 하나가 튀어 오르는 정도를 정확한 수식으로 결정한다.

순위가 오르면 스프링으로 튀어 오르는 행
새 창에서 열기

02 · 순위가 오르면 스프링으로 튀어 오르는 행 — 측정 누적면적 23.776% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LEADERBOARD_RANKUP_OVERSHOOT_PERCENTpopRowLEADERBOARD_RANKUP_POP_MS
  // 계산 겹: 목표 오버슛 비율(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));
  }

02 항목은 누적면적 23.8%·강도 평균 160.4·23프레임 중 22프레임에서 움직였다.

03점수가 오르면 자릿수마다 시간차를 두고 굴러가는 카운터

점수가 오르면 세 자릿수가 슬롯머신처럼 따로 굴러 새 숫자에 멈춘다. rollTo가 점수를 세 자리로 쪼개 왼쪽부터 자릿수 순번(i) 곱하기 LEADERBOARD_DIGIT_ROLL_STAGGER_MS를 그 자리 reel의 transitionDelay로 걸어, 이 값 하나가 자리마다 굴러가는 시간차를 만든다.

점수 숫자가 한 자리씩 굴러 올라가는 카운터
새 창에서 열기

03 · 점수 숫자가 한 자리씩 굴러 올라가는 카운터 — 측정 누적면적 41.486% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LEADERBOARD_DIGIT_ROLL_STAGGER_MSbuildReelLEADERBOARD_DIGIT_H
  // 계산 겹: rollTo() 가 점수 숫자를 3자리로 나눠, 왼쪽부터 자릿수 순번(i) x
  // LEADERBOARD_DIGIT_ROLL_STAGGER_MS 를 그 자릿수 reel 의 transitionDelay 로 걸어
  // 한꺼번에 목표 값으로 바꾼다 — 값을 늘리면 자리마다 굴러가는 시간차가 그만큼 커진다.
  // 실제로 값이 바뀐 자릿수 칸에는 굴러가는 진행 구간과 같은 길이의 배경 하이라이트를
  // 얹어(is-rolling), 숫자가 도는 셀이 눈에 띄게 밝아졌다 가라앉게 한다.
  function rollTo(row, score) {
    var str = String(score);
    while (str.length < 3) str = "0" + str;
    var changed = false;
    for (var i = 0; i < 3; i++) {
      var digit = parseInt(str[i], 10);
      var delay = reduce ? 0 : i * LEADERBOARD_DIGIT_ROLL_STAGGER_MS;
      var dur = reduce ? 0 : LEADERBOARD_DIGIT_ROLL_MS;
      var reel = row.reels[i];
      var box = row.digitBoxes[i];
      reel.style.transitionDelay = delay + "ms";
      reel.style.transitionDuration = dur + "ms";
      reel.style.transform = "translateY(-" + (digit * LEADERBOARD_DIGIT_H) + "px)";
      if (!reduce && row.prevDigits[i] !== digit) {
        changed = true;
        box.style.setProperty("--roll-delay", delay + "ms");
        box.style.setProperty("--roll-ms", dur + "ms");
        box.classList.remove("is-rolling");
        void box.offsetWidth;
        box.classList.add("is-rolling");

03 항목은 누적면적 41.5%·강도 평균 68.6·23프레임 중 8프레임에서 움직였다.

04순위가 높을수록 그림자와 부양 높이가 커지는 행

순위가 위로 갈수록 행 아래 그림자가 넓고 진해지고 카드가 살짝 떠 보인다. xForRank가 순위를 0~1로 정규화한 값 하나를 만들고, elevationForX가 그 값을 블러·오프셋·알파·들뜨는 높이 네 속성에 동시에 곱해 넣는다.

순위가 높을수록 그림자가 떠오르는 행
새 창에서 열기

04 · 순위가 높을수록 그림자가 떠오르는 행 — 측정 누적면적 21.265% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

elevationForLEADERBOARD_ELEVATION_BLUR_MAX_PXapplyElevation
  // 계산 겹: 순위를 0~1 로 정규화한 x 값 하나로 그림자의 세 속성(blur·offset·alpha)과
  // 카드의 들뜨는 높이(lift)를 동시에 만든다 — 상수 네 개가 그 값의 상한을 정한다.
  function elevationFor(rank) {
    return elevationForX(xForRank(rank));
  }

  function elevationForX(x) {
    return {
      blur: 4 + x * LEADERBOARD_ELEVATION_BLUR_MAX_PX,
      offset: 2 + x * LEADERBOARD_ELEVATION_OFFSET_MAX_PX,
      alpha: 0.08 + x * LEADERBOARD_ELEVATION_ALPHA_MAX,
      lift: x * LEADERBOARD_ELEVATION_LIFT_MAX_PX
    };
  }

  function paintElevation(row, e) {
    row.li.style.boxShadow = "0 " + e.offset.toFixed(1) + "px " + e.blur.toFixed(1) + "px rgba(23,20,26," + e.alpha.toFixed(2) + ")";
    row.li.style.transform = "translateY(-" + e.lift.toFixed(1) + "px)";
  }

  function applyElevation(row, rank) {
    paintElevation(row, elevationFor(rank));
  }

04 항목은 누적면적 21.3%·강도 평균 60.6·23프레임 중 11프레임에서 움직였다.

051위 행 위로만 주기적으로 흐르는 금빛 광택

1위 행 위로 대각선 빛줄기가 주기적으로 지나가며 은은하게 반짝인다. sweepFrame이 1위와 2위의 점수 차(margin)로 광택이 도는 주기 자체를 매 프레임 다시 재고, 그 위상을 smoothstep으로 완만하게, sin으로 접어 쓸고 지나가는 가운데서 정점을 찍는 hump로 그림자·배경에까지 전파한다.

1등 행에만 지나가는 금빛 광택
새 창에서 열기

05 · 1등 행에만 지나가는 금빛 광택 — 측정 누적면적 12.883% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LEADERBOARD_GOLD_SWEEP_FAST_MSsmoothstepsweepFrame
  // 계산 겹: 1위와 2위의 점수 차(marginRatio)로 광택이 흐르는 주기를 매 프레임 다시 잰다 —
  // 격차가 클수록 느긋하게, 격차가 좁을수록 빠르게 흐른다. 위치는 smoothstep 로 완만하게 이징한다.
  // 같은 위상(phase)을 sin 으로 접어 "쓸고 지나가는 가운데"에서 정점을 찍는 hump 로 만들고,
  // 1등 행 배경 톤·그림자(box-shadow, overflow:hidden 밖으로 번짐)에 그대로 전파한다.
  function sweepFrame(now) {
    var order = sortedPlayers();
    var goldNode = nodes[order[0].id];
    var margin = order.length > 1
      ? Math.max(0, Math.min(1, (order[0].score - order[1].score) / order[0].score))
      : 1;
    var period = LEADERBOARD_GOLD_SWEEP_FAST_MS + margin * (LEADERBOARD_GOLD_SWEEP_SLOW_MS - LEADERBOARD_GOLD_SWEEP_FAST_MS);
    var phase = (now % period) / period;
    var pos = smoothstep(phase) * 160 - 30;
    goldNode.sweep.style.transform = "translateX(" + pos.toFixed(1) + "%) skewX(-18deg)";
    var hump = Math.sin(phase * Math.PI);
    goldNode.li.style.boxShadow =
      "0 0 " + (6 + hump * 22).toFixed(1) + "px rgba(" + LEADERBOARD_GOLD_RGB + "," + (0.25 + hump * 0.45).toFixed(3) + "), " +
      "0 " + (2 + hump * 9).toFixed(1) + "px " + (10 + hump * 16).toFixed(1) + "px rgba(0,0,0," + (0.22 * hump).toFixed(3) + ")";
    goldNode.li.style.background = "rgba(" + LEADERBOARD_GOLD_RGB + "," + (0.12 + hump * 0.26).toFixed(3) + ")";
    requestAnimationFrame(sweepFrame);

05 항목은 누적면적 12.9%·강도 평균 36.3·23프레임 중 23프레임에서 움직였다.

06손을 떼도 마찰값만큼 더 미끄러지는 순위 목록

드래그를 놓아도 목록이 그 속도만큼 더 미끄러지다가 서서히 멎는다. coastStep이 놓는 순간의 속도(velocity)를 매 프레임 LEADERBOARD_SCROLL_FRICTION 값만큼 곱해 감쇠시키고, 속도가 문턱 아래로 떨어지거나 끝에 닿으면 스스로 멈추는 계산이다.

손을 떼도 관성으로 더 미끄러지는 순위 목록
새 창에서 열기

06 · 손을 떼도 관성으로 더 미끄러지는 순위 목록 — 측정 누적면적 15.895% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LEADERBOARD_SCROLL_FRICTIONcoastStepLEADERBOARD_SCROLL_MIN_VELOCITY
  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 + " 부터 보임";
  }

  // 계산 겹: 놓는 순간의 속도(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);
  }

06 항목은 누적면적 15.9%·강도 평균 79.1·23프레임 중 10프레임에서 움직였다.

07오르내림 폭만큼 커졌다 지수적으로 잦아드는 화살표 배지

순위가 오르면 위쪽, 내리면 아래쪽 화살표가 잠깐 반짝였다 사라진다. flashArrow가 변화량(delta) 크기 비율로 화살표 배율(scale)을 정하고, 그 뒤 Math.exp(-t/tau) 지수감쇠로 알파를 매 프레임 계산해 반짝임의 밝기와 지속 시간을 함께 정한다.

오르내림을 알려주는 화살표 배지
새 창에서 열기

07 · 오르내림을 알려주는 화살표 배지 — 측정 누적면적 31.854% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

flashArrowLEADERBOARD_DELTA_DECAY_TAU_MSLEADERBOARD_DELTA_MAX_REF
    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;
      }

07 항목은 누적면적 31.9%·강도 평균 27.7·23프레임 중 23프레임에서 움직였다.

08점수 비율만큼 자라나는 포디움 막대

각 참가자 아래 막대가 점수 비율만큼 높이로 자라 순위를 막대 길이로도 보여준다. applyBars가 각 선수의 점수를 1위 점수로 나눈 비율(ratio) 하나를 scaleY로 그대로 쓰고, 같은 ratio를 막대 명도·바닥 그림자·1위 기둥 배경에도 함께 퍼뜨린다.

점수만큼 자라나는 포디움 막대
새 창에서 열기

08 · 점수만큼 자라나는 포디움 막대 — 측정 누적면적 18.14% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

applyBarsLEADERBOARD_PODIUM_BAR_MAX_PXslotOrder
  // 계산 겹: 각 선수의 점수를 1위 점수로 나눈 비율(ratio)이 막대의 scaleY 그 자체다 —
  // transform-origin 을 바닥에 두어 레이아웃을 건드리지 않고 순수 계산값만으로 키를 만든다.
  // 같은 ratio 를 막대 명도(lo→hi 보간)·바닥 그림자·1위 기둥 배경에도 전파한다(계산 겹).
  function applyBars(order) {
    var maxScore = order[0].score || 1;
    order.forEach(function (p, i) {
      var ratio = Math.max(0.06, p.score / maxScore);
      var node = nodes[p.id];
      node.bar.style.transform = "scaleY(" + ratio.toFixed(3) + ")";
      node.rank.textContent = String(i + 1);
      node.score.textContent = String(p.score);
      var r = Math.round(BAR_LO_RGB[0] + (BAR_HI_RGB[0] - BAR_LO_RGB[0]) * ratio);
      var g = Math.round(BAR_LO_RGB[1] + (BAR_HI_RGB[1] - BAR_LO_RGB[1]) * ratio);
      var b = Math.round(BAR_LO_RGB[2] + (BAR_HI_RGB[2] - BAR_LO_RGB[2]) * ratio);
      node.bar.style.background = "rgb(" + r + "," + g + "," + b + ")";
      node.wrap.style.boxShadow = "0 " + (ratio * 9).toFixed(1) + "px " + (ratio * 20).toFixed(1) + "px rgba(10,10,14," + (ratio * 0.34).toFixed(3) + ")";
      node.col.style.background = "rgba(" + BAR_HI_RGB.join(",") + "," + (Math.max(0, ratio - 0.45) * 0.16).toFixed(3) + ")";
    });
  }

08 항목은 누적면적 18.1%·강도 평균 167.7·23프레임 중 23프레임에서 움직였다.

09동점이 되면 같은 색으로 함께 반짝이는 두 행

두 행이 겹쳐 한 줄이 되는 게 아니라, 점수가 같은 두 행이 짧게 같은 색으로 함께 반짝여 공동 순위임을 보여준다. computeRanks가 정렬된 점수를 훑어 바로 앞 선수와 값이 같으면 순위를 그대로 물려받는 표준 공동순위 규칙을 계산하고, 그 순위가 두 번 이상 나온 자리에만 lbt__row--tie 클래스를 함께 건다.

동점자를 한 줄로 합치는 깜빡임
새 창에서 열기

09 · 동점자를 한 줄로 합치는 깜빡임 — 측정 누적면적 17.028% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

computeRanksLEADERBOARD_TIE_STEPtieRanks
  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);
    });

09 항목은 누적면적 17.0%·강도 평균 61.6·23프레임 중 9프레임에서 움직였다.

스프링 오버슛 비율과 숫자 굴림 간격, 스크롤 마찰 값이 갈라지는 아홉 계산

세 값이 특히 결과를 크게 가른다. LEADERBOARD_RANKUP_OVERSHOOT_PERCENT는 02의 스프링 zeta를 통째로 바꿔 튀어 오르는 정도를 정하고, LEADERBOARD_DIGIT_ROLL_STAGGER_MS는 03의 세 자릿수가 굴러가는 시간차를 벌리며, LEADERBOARD_SCROLL_FRICTION은 06이 손을 뗀 뒤 얼마나 오래 미끄러질지를 정한다. 아래는 렌더 24프레임을 델타 23개로 잰 아홉 패턴의 실제 변화량이다.

NN 이름 누적면적 % 강도 평균 x/23
01 FLIP 교체 33.7 29.9 7/23
02 스프링 팝 23.8 160.4 22/23
03 숫자 굴림 41.5 68.6 8/23
04 그림자 부양 21.3 60.6 11/23
05 금빛 스윕 12.9 36.3 23/23
06 관성 스크롤 15.9 79.1 10/23
07 델타 화살표 31.9 27.7 23/23
08 포디움 막대 18.1 167.7 23/23
09 동점 반짝임 17.0 61.6 9/23

강도 평균이 가장 큰 건 08(167.7)과 02(160.4)다 — 둘 다 짧은 순간에 값이 크게 튀는 스프링·비율 계산이라 화면이 격하게 반응하지만, 움직인 프레임 수는 각각 23·22로 오래 지속되지는 않는다. 반대로 03은 누적면적이 41.5%로 가장 넓지만 8프레임에서만 움직여 — 세 자릿수 reel이 차지하는 면적은 크되 실제로 굴러가는 순간은 짧다는 뜻이다.

모바일 게임 랭킹 ui 말고도 이 아홉 개를 실제로 쓰는 화면

이 아홉 계산은 큐 항목의 쓰임처럼 각기 다른 화면에 붙는다. 01은 게임 실시간 순위표, 02는 포인트 적립 순위 페이지, 03은 라이브 점수 대시보드, 04는 판매 순위 카드 목록에 맞는다. 모바일 게임 랭킹 ui처럼 순위가 초 단위로 바뀌는 화면일수록 01·07처럼 변화가 일어나는 그 순간에만 계산하는 패턴이 화면을 덜 어지럽힌다.

05는 명예의 전당 페이지, 08은 시상식 결과 화면처럼 순위가 자주 바뀌지 않는 랭킹 보드 ui에 어울린다 — 값이 안정된 화면에서는 07의 재정렬 충돌 같은 문제가 거의 안 생긴다. 06은 모바일 순위 목록 스크롤, 09는 퀴즈 결과 공동 순위 표시처럼 참가자 수가 화면보다 많거나 점수가 겹치기 쉬운 화면에 맞는다.

빈번한 재정렬과 동점에서 아홉 계산이 깨지는 지점

prefers-reduced-motion: reduce가 참이면 01의 flipSwap·07의 flashArrow처럼 애니메이션 함수 안에서 if (reduce) return으로 이동·반짝임 계산 자체를 건너뛰고 순위·점수만 즉시 갱신한다. 06만 다르다 — reduce에서도 startCoast가 관성 없이 방향값의 6배만큼 offset을 한 번에 옮겨, 계산은 남아 있지만 결과가 순간이동으로 보인다.

01은 bumpMany가 LEADERBOARD_FLIP_SWAP_MS(460ms)마다 여러 선수를 동시에 밀어 올리는데, 이전 FLIP 애니메이션(600ms)이 채 끝나기 전에 같은 행이 다시 걸리면 animateFlipRow가 node.anim.cancel()로 진행 중이던 애니메이션을 끊고 새 dy로 다시 시작한다 — 이 취소 로직이 없으면 겹친 transform이 그대로 쌓여 행이 어긋난 자리에 멈춰 버린다.

동점 처리는 09만 따로 계산한다. 01·02·04·05·08은 sortedPlayers().indexOf나 배열 순번(i+1)을 그대로 순위 숫자로 쓰기 때문에, 두 선수 점수가 우연히 같아도 화면엔 연속된 두 숫자(예: 2위·3위)가 나뉘어 뜬다 — 이 순위표를 실서비스에 옮길 때 점수가 같은 참가자가 나오면 이 다섯 항목은 09의 computeRanks처럼 공동 순위 로직을 따로 얹어야 한다. 07은 애초에 순위 숫자를 표시하지 않고 참가자별 점수 변화량만 화살표로 보여주므로 이 문제 자체가 없다.

udavckhu으로 zip을 열면 아홉 개의 index.html·style.scss 원본이 그대로 들어 있어 이 여섯 항목에 09의 동점 판정을 그대로 옮겨 붙여볼 수 있다.

값 세 가지를 바꿔보는 변형과 접근성

값 하나만 바꿔도 반응이 확 달라지는 세 자리가 있다.

이름 값 느낌
순위 상승 스프링 오버슛 비율 값 조정 02 LEADERBOARD_RANKUP_OVERSHOOT_PERCENT 15 → 45 값을 낮추면 순위가 올라도 살짝만 부풀었다 가라앉아 차분하고, 값을 높이면 행이 크게 부풀었다 튕겨 돌아오는 티가 나는 팝이 된다.
점수 숫자 굴림 간격 값 조정 03 LEADERBOARD_DIGIT_ROLL_STAGGER_MS 30 → 120 값을 낮추면 세 자리가 거의 한 번에 바뀌어 보이고, 값을 높이면 왼쪽부터 한 자리씩 순서대로 도는 게 눈에 들어온다.
관성 스크롤 마찰 값 조정 06 LEADERBOARD_SCROLL_FRICTION 0.92 → 0.98 값을 낮추면 손을 뗀 목록이 몇 프레임 만에 서고, 값을 높이면 마찰이 거의 없다시피 해져 한참을 계속 미끄러진다.

접근성은 세 갈래로 갖췄다. prefers-reduced-motion이 걸리면 위 함정 절에서 본 대로 아홉 항목 전부 이동·반짝임 계산을 건너뛰고 순위 숫자만 갱신한다. 각 데모의 <p aria-live="polite">는 01의 "N 이(가) M위로 올라갔습니다", 05의 "N 이(가) 1위, 점수 M", 09의 "N, M 공동 순위"처럼 순위 변화를 스크린리더가 읽을 문장으로 그때그때 채운다. 키보드는 01·02·03·04·05·07·08·09가 각 행에 tabIndex를 걸고 Enter·Space로 그 선수의 점수를 올리며, 06은 뷰포트 자체가 tabindex="0"이라 방향키(위·아래)와 PageUp·PageDown으로 목록을 밀 수 있고 휠에도 반응한다.

랭킹 페이지 디자인과 랭킹 ui 디자인은 뭐가 다른가?

보통 그 이름은 페이지 전체 레이아웃(헤더·필터·페이지네이션)을 가리키는 경우가 많고, 이 글의 랭킹 ui 디자인은 그 안에서 순위 행 하나하나가 값이 바뀔 때 어떻게 움직이는지를 다룬다. 즉 페이지 구성은 그대로 두고 리스트 안 컴포넌트만 바꿔 끼울 수 있다 — 마커나 정렬 표시가 필요하면 CSS 리스트 디자인 9종, 순서를 손으로 옮겨야 하면 드래그 앤 드롭 UI 리스트, 순위 대신 진행률을 보여줘야 하면 게이지 바 9종이 옆에서 쓰인다.

랭킹 ui 디자인을 실제로 쓰기 전에 자주 묻는 것

참가자가 수백 명이면 이 아홉 계산을 그대로 써도 되나요?

01·04·08처럼 매번 전체 목록을 재배치하는 계산은 화면에 보이는 행 수만큼만 순회하도록 가상 스크롤과 함께 쓰는 편이 안전하다. 06은 이미 viewport.clientHeight만큼만 그려 넘치는 목록에 맞춰져 있다.

랭킹 디자인을 다른 목록에도 그대로 옮겨 붙일 수 있나요?

가능하다. 아홉 개 모두 players 배열과 순위 계산 함수가 분리돼 있어, score 필드만 있는 다른 데이터(리뷰 수·좋아요 수 등)로 배열을 바꿔도 정렬·표시 로직은 그대로 동작한다.

09의 동점 판정을 다른 여덟 항목에도 붙일 수 있나요?

붙일 수 있다. 위 함정 절에서 본 대로 computeRanks는 정렬된 배열만 받으면 되므로, 순위 숫자를 매기는 01·02·04·05·08 다섯 항목은 그 자리에서 String(i + 1) 대신 이 함수의 결과를 쓰면 된다. 03·06·07은 애초에 순위 숫자를 화면에 표시하지 않아 이 치환 자체가 필요 없다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.