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

페이지네이션 ui 9가지 — 창 계산부터 스프링 알약까지 고르는 쪽 이동

페이지네이션은 번호 열 개만 늘어놓으면 끝날 것 같지만, 40쪽쯤 되면 칸 수가 흔들리고 현재 위치가 눈에서 사라진다 — 01번이 그 두 군데를 막는다. 페이지네이션 ui 는 긴 목록을 쪽으로 나눠 지금 어디이고 어디로 갈 수 있는지 알려 주는 번호 줄이다.

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

페이지네이션 ui 9가지 — 계산한 창과 스프링이 이동을 매끄럽게 한다

아홉 개는 쪽 번호 줄이 깨지는 순서대로 놓았다. 01 은 칸 수가 변하지 않는 번호 창의 계산이고, 02 는 현재 쪽을 가리키는 알약이 스프링으로 따라가는 장면, 03 은 본문이 어느 방향에서 들어오는지 정하는 전환이다. 04 는 멀리 뛸 때 거쳐 가는 번호가 차례로 켜지는 궤적, 05 는 끝에서 고무줄처럼 튕기는 이전·다음, 06 은 점이 막대로 늘어나는 모바일용 줄이다. 07 은 레일을 끌어 쪽을 고르는 관성 스냅, 08 은 다음만 있는 커서 방식과 지나온 길, 09 는 남은 개수를 링과 숫자로 보여 주는 더 보기 버튼이다.

이런 화면에서 쓴다

쇼핑 목록이 40쪽까지 이어지거나 관리자 화면의 표가 수백 행일 때, 사용자는 지금 몇 쪽인지와 마지막이 어디인지를 번호 줄에서 읽는다. 모바일 페이지네이션 ui 는 손가락이 닿는 폭이 좁아서 번호를 전부 늘어놓지 못하고, 그래서 01 의 창 계산이나 06 의 점 줄처럼 보이는 칸을 줄이는 방식이 먼저 필요하다. 목록 본문이 바뀌는 순간은 03, 현재 위치 표시는 02, 서버가 총 쪽수를 모를 때는 08 로 나눠 읽으면 된다.

이웃 편과 무엇이 다른가

스와이프 삭제 리스트 UI 9가지 — 페이지네이션까지는 번호 말줄임 하나를 스와이프 리스트의 한 항목으로 다뤘다. 이 편은 칸 수 자체를 고정하는 계산, 알약과 본문 전환의 스프링, 먼 점프의 궤적처럼 쪽 번호 줄 하나만 아홉 가지로 깊게 판다.

기법별 코드로 보는 페이지 이동

아홉 항목은 nav 안의 버튼(07 은 슬라이더 손잡이, 09 는 더 보기 버튼 하나)과 현재 쪽을 읽어 주는 숨은 문단으로 구성되고, 아래에는 이동을 계산하는 핵심 구간만 발췌했다. 쪽은 서버에서 받지 않고 상수로 정한 총 쪽수 안에서 번호만 바꾸며, 시연 중에 직접 누르거나 키를 치면 자동 시연이 멈추고 같은 계산이 입력을 받는다. 나머지 마크업과 스타일은 데모 아래 압축 파일에 통째로 들어 있다.

01칸 수가 변하지 않는 번호 창

windowSlots 는 총 쪽수와 칸 수를 받아 언제나 같은 길이의 배열을 돌려주고, GAP 값 -1 이 말줄임 칸이 된다. 현재 쪽이 4 이하이면 1부터 5 와 끝 쪽, 37 이상이면 첫 쪽과 36부터 40, 그 사이는 첫 쪽·가운데 세 쪽·끝 쪽을 고르므로 어느 쪽에서도 줄 너비가 같다. 01 항목은 누적면적 5.6%·강도 평균 71.5·움직인 프레임 20/23 이다.

자리 수가 고정된 페이지 창
새 창에서 열기

01 · 자리 수가 고정된 페이지 창 — 측정 누적면적 5.581% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SLOT_COUNTTOTAL_PAGESGAP
  function windowSlots(cur, total, slots) {
    var out = [];
    var i;
    if (total <= slots) {
      for (i = 1; i <= total; i += 1) out.push(i);
      return out;
    }
    var inner = slots - 4;
    if (cur <= slots - 3) {
      for (i = 1; i <= slots - 2; i += 1) out.push(i);
      out.push(GAP, total);
      return out;
    }
    if (cur >= total - slots + 4) {
      out.push(1, GAP);
      for (i = total - slots + 3; i <= total; i += 1) out.push(i);
      return out;
    }
    var lo = cur - Math.floor((inner - 1) / 2);
    out.push(1, GAP);
    for (i = 0; i < inner; i += 1) out.push(lo + i);
    out.push(GAP, total);
    return out;
  }

02스프링으로 미끄러지는 활성 알약

알약은 위치 x 와 속도 v 를 들고 -PILL_STIFFNESS * (x - target) - PILL_DAMPING * v 로 가속도를 구해 1/240초씩 쪼개 적분하므로 도착점을 살짝 넘었다 돌아온다. 가로로 늘어나는 정도는 이동 거리가 아니라 그 순간의 속도에 비례해서, 알약이 멈추는 순간에는 원래 모양으로 돌아온다. 02 항목은 누적면적 7.7%·강도 평균 180.0·움직인 프레임 21/23 이다.

스프링으로 미끄러지는 활성 알약
새 창에서 열기

02 · 스프링으로 미끄러지는 활성 알약 — 측정 누적면적 7.7% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PILL_STIFFNESSPILL_DAMPINGSTRETCH_GAINSTRETCH_MAX
    var target = slotX(cur);
    var left = dt;
    while (left > 0) {
      var h = Math.min(SUBSTEP, left);
      v += (-PILL_STIFFNESS * (x - target) - PILL_DAMPING * v) * h;
      x += v * h;
      left -= h;
    }
    var span = target - fromX;
    if (span !== 0) {
      var over = ((x - target) * Math.sign(span)) / Math.abs(span);
      if (over > peak) peak = over;
      overEl.textContent = String(Math.round(peak * 100));
    }

03방향을 아는 콘텐츠 전환

다음 쪽으로 가면 새 카드가 오른쪽에서, 이전 쪽으로 가면 왼쪽에서 들어오고, 이동 폭 slide·흐림 blur·시간 ms 는 건너뛴 쪽수 dist 가 클수록 상한까지 커진다. 한 쪽 이동은 42px 이고 7쪽 이상은 상한 120px 로 고정된다. 03 항목은 누적면적 19.4%·강도 평균 43.0·움직인 프레임 19/23 이다.

방향을 아는 콘텐츠 전환
새 창에서 열기

03 · 방향을 아는 콘텐츠 전환 — 측정 누적면적 19.364% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SLIDE_PER_PAGESLIDE_MAXBLUR_PER_PAGEMS_PER_PAGE
    fillCard(cardA, prevPage);
    fillCard(cardB, cur);
    anim = {
      t: now,
      dir: dir,
      slide: Math.min(SLIDE_MAX, BASE_SLIDE + dist * SLIDE_PER_PAGE),
      blur: Math.min(BLUR_MAX, dist * BLUR_PER_PAGE),
      ms: Math.min(MS_MAX, BASE_MS + dist * MS_PER_PAGE)
    };
  }

04멀리 뛸 때 번호가 차례로 번쩍이는 궤적

1쪽에서 40쪽처럼 멀리 뛰면 setLabels 가 출발쪽과 도착쪽 사이를 같은 간격으로 나눠 아홉 개 칩에 번호를 붙이고, 칩마다 |쪽 - 출발| * STAGGER_STEP 밀리초 늦게 켜진다. 먼 번호일수록 늦게 켜지므로 눈이 출발에서 도착까지 길을 따라간다. 04 항목은 누적면적 14.0%·강도 평균 160.1·움직인 프레임 22/23 이다.

멀리 뛸 때 번호가 차례로 번쩍이는 궤적
새 창에서 열기

04 · 멀리 뛸 때 번호가 차례로 번쩍이는 궤적 — 측정 누적면적 13.989% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STAGGER_STEPCHIP_COUNTATTACK_MSDECAY_MS
  function setLabels(a, b) {
    labels = [];
    delays = [];
    for (var s = 0; s < CHIP_COUNT; s += 1) {
      var page = Math.round(a + ((b - a) * s) / (CHIP_COUNT - 1));
      labels.push(page);
      delays.push(Math.abs(page - a) * STAGGER_STEP);
      chips[s].textContent = String(page);
    }
    totalMs = Math.abs(b - a) * STAGGER_STEP + ATTACK_MS + DECAY_MS;
  }

05끝에서 고무줄처럼 튕기는 이전·다음

끝 쪽에서 이전이나 다음을 누르면 쪽은 그대로 두고 PUSH_IMPULSE 만큼 버튼에 충격을 주며, 이미 밀려 있을수록 slack 이 줄어 충격도 작아진다. 밀림은 스프링으로 되돌아오고 가운데 라벨과 카드가 그 일부를 따라 움직인다. 05 항목은 누적면적 8.2%·강도 평균 75.7·움직인 프레임 22/23 이다.

끝에서 고무줄처럼 튕기는 이전·다음
새 창에서 열기

05 · 끝에서 고무줄처럼 튕기는 이전·다음 — 측정 누적면적 8.199% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PUSH_IMPULSERUBBER_LIMITEDGE_STIFFNESSEDGE_DAMPING
  function press(dir, byUser) {
    if (byUser) root.classList.remove('is-demo');
    var side = dir < 0 ? 0 : 1;
    var target = cur + dir;
    if (target < 1 || target > TOTAL_PAGES) {
      if (!reduce) {
        var slack = Math.max(0, 1 - Math.abs(x[side]) / RUBBER_LIMIT);
        v[side] += dir * PUSH_IMPULSE * slack;
      }
      if (byUser) sr.textContent = '현재 ' + cur + ' / ' + TOTAL_PAGES;
      return;
    }
    cur = target;
    syncEdges();
    if (byUser) sr.textContent = '현재 ' + cur + ' / ' + TOTAL_PAGES;
  }

06점이 막대로 늘어나는 모바일 페이저

점마다 현재 쪽과의 거리 dist 로 목표 너비와 투명도를 정하고 layout 이 지수 보간으로 따라가서, 현재 쪽은 24px 막대가 되고 멀어질수록 10·8·6px 점이 된다. 4쪽 이상 떨어진 점은 투명해지므로 40쪽이어도 화면에는 막대 하나와 양옆 세 개씩 일곱 개만 남는다. 06 항목은 누적면적 14.1%·강도 평균 70.0·움직인 프레임 22/23 이다.

점이 막대로 늘어나는 모바일 페이저
새 창에서 열기

06 · 점이 막대로 늘어나는 모바일 페이저 — 측정 누적면적 14.051% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DOT_REACHBAR_WDOT_WMORPH_RATE
  function targetW(dist) { return dist < DOT_W.length ? DOT_W[dist] : DOT_W[DOT_W.length - 1]; }
  function targetA(dist) { return dist < DOT_ALPHA.length ? DOT_ALPHA[dist] : 0; }

  function layout(k) {
    var x = 0;
    var centerX = 0;
    for (var s = 0; s < TOTAL_PAGES; s += 1) {
      var dist = Math.abs(s + 1 - cur);
      if (k >= 1) { w[s] = targetW(dist); a[s] = targetA(dist); } else {
        w[s] += (targetW(dist) - w[s]) * k;
        a[s] += (targetA(dist) - a[s]) * k;
      }
      dots[s].style.width = w[s].toFixed(2) + 'px';
      dots[s].style.opacity = a[s].toFixed(3);
      dots[s].className = 'pg__dot' + (s + 1 === cur ? ' is-cur' : '');
      dots[s].style.transform = 'translateX(' + x.toFixed(2) + 'px)';
      if (s + 1 === cur) centerX = x;
      x += w[s] + DOT_GAP;
    }
    return centerX;
  }

07레일을 끌어 쪽을 고르는 관성 스냅

pointerMove 가 포인터 순간 속도를 VELOCITY_BLEND 비율로 섞어 v 에 담고, 손을 놓으면 release 가 관성 미끄러짐으로 넘겨 느려졌을 때 가까운 눈금에 붙인다. 말풍선은 속도에 따라 기울고 손잡이는 role="slider" 라서 키보드로도 쪽을 고를 수 있다. 07 항목은 누적면적 15.1%·강도 평균 98.5·움직인 프레임 9/23 이다.

레일을 끌어 쪽을 고르는 관성 스냅
새 창에서 열기

07 · 레일을 끌어 쪽을 고르는 관성 스냅 — 측정 누적면적 15.103% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FRICTIONSNAP_SPEEDSNAP_RATEVELOCITY_BLEND
  function pointerMove(px, now) {
    var nx = Math.max(0, Math.min(RAIL_W, px));
    var dt = Math.max(0.004, (now - lastMoveTs) / 1000);
    var inst = (nx - lastX) / dt;
    v = v * (1 - VELOCITY_BLEND) + inst * VELOCITY_BLEND;
    lastX = nx;
    lastMoveTs = now;
    x = nx;
    mode = 'drag';
  }

  function release() {
    if (mode !== 'drag') return;
    if (reduce) { x = tickX(nearestPage(x)); v = 0; mode = 'idle'; return; }
    mode = 'glide';
  }

08다음만 있는 커서 페이저와 지나온 길

goNext 는 reached 보다 멀리 가면 지나온 점을 하나 늘리고, 점을 누르면 goBack 이 그 쪽으로 돌아온다. 쪽 번호 대신 cursorOf 가 만든 네 글자 문자열이 커서 자리를 맡고, 끝에 닿으면 다음 버튼이 aria-disabled 가 된다. 08 항목은 누적면적 14.5%·강도 평균 72.4·움직인 프레임 18/23 이다.

다음만 있는 커서 페이저와 지나온 길
새 창에서 열기

08 · 다음만 있는 커서 페이저와 지나온 길 — 측정 누적면적 14.542% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

END_ATTRAIL_WMAX_GAPPOP_MS
  function goNext(byUser, now) {
    if (byUser) root.classList.remove('is-demo');
    if (cur >= END_AT) return;
    cur += 1;
    if (cur > reached) { reached = cur; if (!reduce) born[cur - 1] = now; }
    sync(now, byUser);
  }

  function goBack(page, byUser, now) {
    if (byUser) root.classList.remove('is-demo');
    if (page < 1 || page > reached || page === cur) return;
    cur = page;
    sync(now, byUser);
  }

09남은 개수를 링으로 보여 주는 더 보기

링은 loaded / TOTAL_ITEMS 를 목표로 스프링처럼 차오르고, 가운데 숫자는 남은 개수를 지수 보간으로 굴려 내린다. 링은 불러온 비율이고 남은 개수는 숫자가 맡으며, 0 이 되면 숫자 대신 체크 표시와 완료 라벨이 나온다. 09 항목은 누적면적 11.6%·강도 평균 113.3·움직인 프레임 23/23 이다.

남은 개수를 링으로 보여 주는 더 보기
새 창에서 열기

09 · 남은 개수를 링으로 보여 주는 더 보기 — 측정 누적면적 11.583% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PAGE_SIZERING_STIFFNESSRING_DAMPINGNUM_RATE
    var target = loaded / TOTAL_ITEMS;
    var left = dt;
    while (left > 0) {
      var h = Math.min(SUBSTEP, left);
      fv += (-RING_STIFFNESS * (frac - target) - RING_DAMPING * fv) * h;
      frac += fv * h;
      left -= h;
    }
    numShown += ((TOTAL_ITEMS - loaded) - numShown) * (1 - Math.exp(-dt * NUM_RATE));
    paintStatic();

값이 장면을 바꾸는 곳 — 창 크기·스프링 강성·점프 지연

세 상수가 줄의 모양과 이동의 느낌을 거의 다 정한다. 아래 표의 수치는 항목마다 렌더한 23개 프레임 간격에서 잰 값이다.

항목 누적면적 % 강도 평균 움직인 프레임
01 고정 칸 창 5.6% 71.5 20/23
02 스프링 알약 7.7% 180.0 21/23
03 방향 전환 19.4% 43.0 19/23
04 번호 궤적 14.0% 160.1 22/23
05 끝 고무줄 8.2% 75.7 22/23
06 점에서 막대 14.1% 70.0 22/23
07 관성 레일 15.1% 98.5 9/23
08 커서 길 14.5% 72.4 18/23
09 링 더 보기 11.6% 113.3 23/23

누적면적이 10% 아래인 01·02·05 는 번호 몇 개와 알약 하나가 움직일 뿐이라 컴포넌트 편에서는 정상이다. 07 이 9/23 으로 낮은 것은 손잡이가 정착해 멈추는 구간이 길기 때문이고, 끌고 있는 동안에는 움직인다.

칸 수가 줄 너비를 정한다

01 의 칸은 flex: 0 1 32px 라서 칸이 9개로 늘어도 320px 폭의 폰에서 줄어들며 한 줄에 들어간다. 말줄임 칸이 숨기는 쪽은 최소 2개이고, 그 칸을 누르면 숨은 구간의 가운데 쪽으로 이동한다.

감쇠비가 오버슛을 정한다

02 는 PILL_DAMPING 17 과 PILL_STIFFNESS 320 에서 감쇠비가 약 0.48 이라 이론 오버슛이 약 18% 이고, 09 는 감쇠 15 와 강성 220 에서 약 0.51 로 오버슛이 약 16% 다. 05 의 끝 버튼 스프링은 강성 260 에 감쇠 15 라서 감쇠비 약 0.47 이다.

점프 지연이 걸리는 시간을 정한다

04 의 총 시간은 |도착 - 출발| * 11 + 270 밀리초이므로 1쪽에서 40쪽은 약 0.7초이고 한 쪽 이동은 거의 순간이다. 03 도 같은 원리로 건너뛴 쪽수가 클수록 이동 시간이 한 쪽 이동의 290ms 에서 최대 560ms 까지 늘어난다.

어디서 깨지는가 — 쪽수 급증·빠른 연타·좁은 화면

칸 수를 5 아래로 줄이면

01 은 SLOT_COUNT 가 5 미만이면 inner 가 0 이하가 되어 현재 쪽이 창에서 빠지고, 짝수이면 가운데 구간이 좌우 비대칭이 된다. 칸 수는 홀수로 5 이상을 유지하는 것이 이 계산의 전제다.

점프 거리가 8쪽보다 짧으면

04 는 거리가 8쪽 미만이면 아홉 칩에 같은 번호가 겹친다. 1쪽에서 3쪽으로 가면 1 이 두 칸, 2 가 네 칸, 3 이 세 칸에 찍히므로, 짧은 이동에서는 궤적을 끄거나 칩 수를 줄이는 쪽이 낫다.

먼 점프와 손가락 선택

06 은 먼 점프에서 새 영역의 점이 투명에서 올라오느라 한두 프레임 비어 보이고, 점 자체는 터치 대상이 아니라 이전·다음 말고는 직접 이동 수단이 없다. 07 은 눈금 간격이 240/23 으로 약 10.4px 라서 20px 손잡이로 인접한 쪽을 손가락으로 고르기 어려워 키보드 조작을 함께 둔다. 아홉 개를 통째로 열어 직접 값을 바꿔 보려면 내려받은 압축 파일의 비밀번호 sj7cvb6m 을 입력해 푼다.

변형 세 가지와 접근성

항목 상수 값 느낌
01 SLOT_COUNT 7 → 9 가운데 구간이 번호 5개로 넓어져 17쪽에서 15·16·17·18·19 가 보이고, 앞쪽은 1쪽부터 7쪽까지 말줄임 없이 이어지며 칸 수는 어느 쪽에서나 같다
02 PILL_STIFFNESS 320 → 160 감쇠비가 약 0.48 에서 약 0.67 로 올라 오버슛 이론값이 약 18% 에서 약 6% 로 줄고 알약이 번호를 따라오는 시간은 길어진다
04 STAGGER_STEP 11 → 22 1쪽에서 40쪽으로 뛰는 궤적이 약 0.7초에서 약 1.1초로 길어지고 번호가 하나씩 또렷하게 켜진다

아홉 항목 모두 숨은 aria-live="polite" 문단이 현재 위치를 읽어 주고, 07 의 손잡이는 role="slider" 에 aria-valuenow 를 갱신한다. 01·02·03·04·05·06·08 은 nav 에 aria-label 을 달아 이동 영역임을 알리고, aria-current="page" 는 01·02·04·08 의 현재 쪽 칸에 붙는다. 03·05·06 은 쪽 번호 칸이 없어 현재 쪽을 문단과 라벨로 알린다. prefers-reduced-motion 이 reduce 이면 자동 시연을 끄고 최종 상태를 바로 그린다. 01 은 17쪽에 고정되고 02 의 알약은 즉시 도착하며 03 의 카드는 즉시 교체된다. 04 는 40쪽을 바로 보이고 05 는 끝에서 눌러도 밀리지 않으며 06 은 보간 없이 17쪽에 고정된다. 07 은 9쪽에 고정되고 끌면 바로 따라가서 놓으면 가까운 눈금에 붙는다. 08 은 팝과 간격 보간 없이 바로 바뀌고 09 는 호와 팝 없이 한 번에 갱신된다. 07 의 포인터 이벤트 는 마우스와 터치를 같은 경로로 받는다.

페이지네이션 디자인은 번호를 몇 개까지 보여 줘야 하는가?

이 편의 기본값은 칸 7개이고, 폭이 넉넉하면 9개로 늘려도 01 의 계산이 그대로 맞는다. 번호를 몇 개 보일지보다 중요한 것은 어느 쪽에서도 칸 수가 같다는 점이라서, 보이는 번호 수는 화면 폭에서 거꾸로 정하면 된다.

번호 줄 대신 다른 이동 방식이 필요하면 이웃 편을 이어 읽자. 끝없이 이어지는 목록은 무한 스크롤 로딩 UI 패턴 9종 — 스피너부터 가상 윈도우까지, 당겨서 갱신하는 동작은 당겨서 새로고침 9종 — 당긴 거리 값이 새로고침을 만드는 방식이 맡는다. 행이 펼쳐지는 표는 아코디언 테이블 9종 — 행이 펼쳐지고 접히는 아홉 가지 방식, 쪽 단위로 나뉘는 댓글 목록은 댓글 ui 9가지 — 대댓글 깊이부터 삭제 뒤 빈 자리까지 계산하는 목록을 열면 된다.

페이지네이션을 쓰기 전에 자주 묻는 질문

페이지네이션 구현에서 칸 수를 고정하면 무엇이 좋은가요?

현재 쪽이 어디든 줄이 같은 너비를 차지해서 쪽을 넘길 때 버튼 위치가 움직이지 않고, 눌러 둔 손가락이 다음 번호를 그대로 누를 수 있다. 01 의 windowSlots 한 함수만 있으면 되고 칸 수는 SLOT_COUNT 상수 하나로 바꾼다.

서버 커서 방식도 같은 컴포넌트로 만들 수 있나요?

총 쪽수를 모르면 번호 줄 대신 08 처럼 다음 버튼 하나와 지나온 점으로 만든다. 데모의 END_AT 는 서버가 끝을 알려 주는 신호를 흉내 낸 상수이고, 실제 커서 API 는 이전으로 돌아갈 커서를 따로 저장해야 한다.

더 보기 버튼과 번호 줄은 어떻게 고르나요?

사용자가 특정 쪽으로 돌아가야 하는 목록이면 번호 줄이, 끝까지 훑는 목록이면 09 같은 더 보기가 맞다. 09 는 링과 남은 개수를 함께 보여 줘서 얼마나 더 남았는지 알려 주지만 쪽을 바로 지정하지는 못한다.

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

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