GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

ページネーション デザイン 9パターン — 窓の計算からばねのピルまで

ページネーションは番号を十個並べれば終わりに見えるが、40ページ付近で枠の数が揺れ現在地が見えなくなる — 01番がその二か所を塞ぐ。ページネーション デザインは、長い一覧をページに分けて現在地と行き先を示す番号列の見せ方です。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

ページネーション デザイン 9パターン — 計算した窓とばねが移動をなめらかにする

九つは、ページ番号の列が崩れやすい順に並べました。01 は枠の数が変わらない番号の窓の計算、02 は現在のページを指すピルがばねで追いかける場面、03 は本文がどちらの向きから入るかを決める切り替えです。04 は遠く飛ぶときに途中の番号が順に光る軌跡、05 は端でゴムのように弾む前へ・次へ、06 はドットが棒に伸びるモバイル用の列になります。07 はレールをなぞってページを選ぶ慣性スナップ、08 は次へだけのカーソル方式と来た道、09 は残りの数をリングと数字で見せるもっと見るボタンです。

こんな画面で使います

買い物の一覧が40ページまで続くときや、管理画面の表が数百行になるとき、利用者は今何ページ目か、終わりがどこかを番号の列から読み取ります。ページネーション デザイン スマホ向けでは指の届く幅が狭く、番号を全部並べられないので、01 の窓の計算や 06 のドット列のように見える枠を減らす方式が先に要ります。一覧の本文が切り替わる瞬間は 03、現在地の印は 02、サーバーが総ページ数を知らないときは 08 と分けて読めば迷いません。

隣の記事とどこが違うか

スワイプ削除リストUI 9選 — ページネーションまでは、番号の省略記号を一つの項目として扱いました。この記事は枠の数そのものを固定する計算、ピルと本文切り替えのばね、遠いジャンプの軌跡のように、ページ番号の列だけを九通りで深く掘ります。

技法別に見るページ移動のコード

九項目はどれも現在のページを読み上げる隠した段落を持ち、01〜06 と 08 は nav の中のボタン、07 はレールの上のつまみ、09 は nav のない「もっと見る」ボタンでできていて、下には移動を計算する中心の区間だけを抜き出しました。ページはサーバーから受け取らず、定数で決めた総ページ数の中で番号だけを切り替えます。実演中に自分で押すかキーを打つと自動の実演は止まり、同じ計算が入力を受けます。残りのマークアップとスタイルは、デモの下の zip にそのまま入っています。

01枠の数が固定のページ窓

windowSlots は総ページ数と枠の数を受け取り、いつも同じ長さの配列を返し、GAP の値 -1 が省略記号の枠になります。現在のページが4以下なら1から5と最後、37以上なら最初と36から40、その間は最初・中央の三つ・最後を選ぶので、どのページでも列の幅は変わりません。計測は面積5.6%、強度平均71.5、23フレーム中20フレームで動きがありました。

枠の数が固定のページ窓
新しいタブで開く

01 · 枠の数が固定のページ窓 — 計測 累積面積 5.581% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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秒ずつ刻んで積分するので、目標を少し行き過ぎて戻ります。横に伸びる量は移動距離ではなくその瞬間の速さに比例し、ピルが止まる瞬間には元の形へ戻ります。計測は面積7.7%、強度平均180.0、23フレーム中21フレームで動きがありました。

ばねで滑るアクティブなピル
新しいタブで開く

02 · ばねで滑るアクティブなピル — 計測 累積面積 7.7% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 が大きいほど上限まで大きくなります。1ページ移動は42px、7ページ以上は上限の120pxに固定です。計測は面積19.4%、強度平均43.0、23フレーム中19フレームで動きがありました。

向きを知るコンテンツ切り替え
新しいタブで開く

03 · 向きを知るコンテンツ切り替え — 計測 累積面積 19.364% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 ミリ秒だけ遅れて点きます。遠い番号ほど遅く点くので、目が出発から到着までの道筋を追えます。計測は面積14.0%、強度平均160.1、23フレーム中22フレームで動きがありました。

遠く飛ぶとき番号が順に光る軌跡
新しいタブで開く

04 · 遠く飛ぶとき番号が順に光る軌跡 — 計測 累積面積 13.989% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 が減って衝撃も小さくなるので、端に近いほど弾みが小さくなります。押された分はばねで戻り、中央のラベルとカードがその一部を追って動きます。計測は面積8.2%、強度平均75.7、23フレーム中22フレームで動きがありました。

端でゴムのように弾む前へ・次へ
新しいタブで開く

05 · 端でゴムのように弾む前へ・次へ — 計測 累積面積 8.199% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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ページでも画面には棒一本と両脇に三つずつの七つだけが残ります。計測は面積14.1%、強度平均70.0、23フレーム中22フレームで動きがありました。

ドットが棒に伸びるモバイル用ページャー
新しいタブで開く

06 · ドットが棒に伸びるモバイル用ページャー — 計測 累積面積 14.051% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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" なのでキーボードでもページを選べます。計測は面積15.1%、強度平均98.5、23フレーム中9フレームで動きがありました。

レールをなぞって選ぶ慣性スナップ
新しいタブで開く

07 · レールをなぞって選ぶ慣性スナップ — 計測 累積面積 15.103% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 になります。計測は面積14.5%、強度平均72.4、23フレーム中18フレームで動きがありました。

次へだけのカーソルページャーと来た道
新しいタブで開く

08 · 次へだけのカーソルページャーと来た道 — 計測 累積面積 14.542% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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になると数字の代わりにチェックの印と完了のラベルが出ます。計測は面積11.6%、強度平均113.3、23フレーム中23フレームで動きがありました。

残りの数をリングで見せるもっと見る
新しいタブで開く

09 · 残りの数をリングで見せるもっと見る — 計測 累積面積 11.583% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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秒で、1ページ移動はほぼ一瞬です。03 も同じ原理で、移動時間は260msに1ページごと30msを足し、560msで頭打ちになります。1ページの移動なら290msです。

どこで壊れるか — ページ数の急増・連打・狭い画面

枠の数を5より小さくすると

01 は SLOT_COUNT が5未満だと inner が0以下になって現在のページが窓から外れ、偶数だと中央の区間が左右非対称になります。枠の数は5以上の奇数に保つことが、この計算の前提です。

ジャンプの距離が8ページより短いと

04 は距離が8ページ未満だと九つのチップに同じ番号が重なります。1ページから3ページへ進むと Math.round の割り当てで、1が二つの枠、2が四つの枠、3が三つの枠に出るので、短い移動では軌跡を切るかチップの数を減らすほうがよい結果になります。

大きなカードのぼかしと連打

03 は切り替えの途中でもう一度押すと、出ていくカードを直前の到着ページで埋め直すので、途中の場面が一度跳ねます。大きなカードに filter: blur をかけると、低性能の端末ではフレームが落ちるので、カードが大きいときはぼかしの上限 BLUR_MAX を下げてください。

遠いジャンプと指での選択

06 は遠いジャンプで新しい範囲のドットが透明から上がってくるため、一、二フレームは空いて見え、ドット自体はタッチの対象ではないので、前へ・次へのほかに直接移る手段がありません。07 は目盛りの間隔が 240/23 で約10.4pxなので、20pxのつまみで隣のページを指で選ぶのは難しく、キーボード操作を併せて置いています。九つをまるごと開いて自分で値を変えてみるには、ダウンロードした zip を解凍するときにパスワード sj7cvb6m を入力してください。

変形3つとアクセシビリティ

項目 定数 値 変わり方
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" の段落は九項目すべてにあり、現在の位置を読み上げます。nav に aria-label を付けて移動の領域だと伝えるのは 01〜06 と 08 で、現在のページのボタンに aria-current="page" を置くのは 01・02・04・08 です。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選 — 行が開閉する9つの方式、ページ単位で分かれるコメントの一覧は コメント欄 css 実装9パターン — 深さから削除後の詰めまで計算するリスト を開けば読めます。

ページネーションを使う前によくある質問

ページネーション デザイン css で枠の数を固定すると何がよいですか

現在のページがどこでも列が同じ幅を占めるので、ページをめくってもボタンの位置が動かず、押したままの指が次の番号をそのまま押せます。01 の windowSlots 一つの関数があれば足り、枠の数は SLOT_COUNT という定数一つで変えられます。

サーバーのカーソル方式も同じ部品で作れますか

総ページ数が分からないときは、番号の列の代わりに 08 のように次へのボタン一つと通った点で作ります。デモの END_AT はサーバーが終わりを知らせる合図を真似た定数で、実際のカーソル API は戻るためのカーソルを別に保存する必要があります。

もっと見るボタンと番号の列はどう選べばよいですか

利用者が特定のページへ戻る必要がある一覧なら番号の列、最後まで流し読みする一覧なら 09 のようなもっと見るが合います。09 はリングと残りの数を一緒に見せて、どれだけ残っているかを伝えますが、ページを直接指定することはできません。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。