GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Pagination CSS: 9 Pagers with Spring Pills

Pagination looks finished once ten numbers sit in a row, until page 40 makes the row change width and the current spot vanishes — scene 01 closes both gaps.

Click Through All Nine Pagination Patterns on This Page

Every cell in the grid below runs its own scripted paging as soon as it is on screen, and the moment you click a button or press a key the script stops and your input goes through the same calculation. Page counts are constants and no request is made, so what you see is only the movement between numbers.

Auto-plays · click a tile to jump to its section · all nine in one zip

Pagination CSS: 9 Pagers Where Windows and Springs Smooth the Move

The nine are ordered by where a page row breaks first. Item 01 fixes the number of slots, 02 makes the marker for the current page travel on a spring, and 03 decides which side the new content arrives from. Item 04 lights the numbers a long jump passes over, 05 lets the previous and next buttons stretch at the ends, and 06 turns the current dot into a bar for narrow screens. Item 07 is a rail you drag and release onto a tick, 08 keeps only a next button plus the path already walked, and 09 shows what is left to load as a ring and a number.

Where These Pagers Fit

A shop list that runs to 40 pages or an admin table with hundreds of rows depends on the page row to say which page this is and where the end lies. Among pagination css examples the common failure is a row that grows and shrinks as the current page moves, so 01 and 06 cut the visible slots first, 03 handles the moment the list body changes, and 08 covers feeds where the total is unknown.

How This Set Differs from the Ellipsis Row

9 Swipe to Delete List UI Patterns (Copy-Paste) treats one numbered ellipsis row as a single item inside a swipe list. This set spends all nine items on the page row alone: a fixed slot count, spring pills, the trail across a long jump and an elastic end.

Code for Each Paging Technique

Items 01 to 06 and 08 are one nav with its buttons, 07 is a slider thumb on a rail and 09 is a single load-more button, each with a hidden status paragraph, and the snippets keep only the lines that move the page. Anyone searching for a pagination css codepen to fork will find the rest of the markup and styles in the zip, where every demo opens as plain files you can edit. Pages stay inside a constant total, so nothing here depends on a server.

01Page Window with a Fixed Slot Count

windowSlots takes the total and the slot count and always returns an array of the same length, and the GAP value of -1 becomes the ellipsis slot. At page 4 or below it shows 1 to 5 and the last page, at 37 or above the first page and 36 to 40, and in between the first page, three around the current one and the last, so the row has the same width on every page. Item 01 covered 5.6% of the frame at an average intensity of 71.5 across 20/23 measured frames.

Page Window with a Fixed Slot Count
Open in a new tab

01 · Page Window with a Fixed Slot Count — Measured area 5.581% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

02Active Pill That Slides on a Spring

The pill behind the current number chases its target on a spring stepped in 1/240-second slices, so it passes the number slightly and then settles. While it moves fast it stretches along the travel direction by sx = 1 + min(0.7, |v| * 0.0011) and shrinks the other axis to keep its area, which makes a long hop look like a smear and a one-step hop like a nudge. Item 02 covered 7.7% of the frame at an average intensity of 180.0 across 21/23 measured frames.

Active Pill That Slides on a Spring
Open in a new tab

02 · Active Pill That Slides on a Spring — Measured area 7.7% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

03Direction-Aware Content Swap

Next brings the new card in from the right and previous brings it in from the left, and the slide is min(120, 28 + distance * 14) pixels with a blur of min(6, distance * 0.9) pixels, so a five-page jump travels farther and blurs deeper than a single step. The outgoing card moves half as far the other way, and the duration is 260 ms plus 30 ms per page, capped at 560 ms, so a one-page move takes 290 ms. Item 03 covered 19.4% of the frame at an average intensity of 43.0 across 19/23 measured frames.

Direction-Aware Content Swap
Open in a new tab

03 · Direction-Aware Content Swap — Measured area 19.364% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

04Numbers That Flash in Turn on a Long Jump

On a jump such as page 1 to page 40, nine chips take pages spread evenly between the two with Math.round, and chip i lights after |page - start| * STAGGER_STEP milliseconds, rising for 50 ms and then decaying exponentially. A pink dot rides the same schedule along the trail line, so the eye follows the flash and does not land on 40 cold. Item 04 covered 14.0% of the frame at an average intensity of 160.1 across 22/23 measured frames.

Numbers That Flash in Turn on a Long Jump
Open in a new tab

04 · Numbers That Flash in Turn on a Long Jump — Measured area 13.989% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

05Prev/Next That Rubber-Bands at the Edges

At an end page the button stays focusable with aria-disabled="true" and dims to 0.55 opacity, and a press toward the wall adds an impulse of PUSH_IMPULSE multiplied by how much slack is left. The slack shrinks as the button is already pushed out to RUBBER_LIMIT (26 px), so repeated presses stretch it less each time, while the centre label follows at 0.3 and the card at 0.7 of that offset. Item 05 covered 8.2% of the frame at an average intensity of 75.7 across 22/23 measured frames.

Prev/Next That Rubber-Bands at the Edges
Open in a new tab

05 · Prev/Next That Rubber-Bands at the Edges — Measured area 8.199% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

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 = 'Page ' + cur + ' / ' + TOTAL_PAGES;
      return;
    }
    cur = target;
    syncEdges();
    if (byUser) sr.textContent = 'Page ' + cur + ' / ' + TOTAL_PAGES;
  }

06Mobile Pager Whose Dot Stretches into a Bar

Each dot takes a target width and opacity from its distance to the current page: a 24-pixel bar for the current one, then 10, 8 and 6 pixels, and anything four or more pages away fades out, which leaves seven visible dots on 40 pages. The widths follow with an exponential blend at MORPH_RATE 14 per second and the strip slides so the bar stays centred in the 120-pixel viewport, which is why css pagination dots stay at seven however many pages exist. Item 06 covered 14.1% of the frame at an average intensity of 70.0 across 22/23 measured frames.

Mobile Pager Whose Dot Stretches into a Bar
Open in a new tab

06 · Mobile Pager Whose Dot Stretches into a Bar — Measured area 14.051% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

07Rail Scrubbing with Inertia and Snap

Release the thumb and its speed carries on as x += v * dt with v *= exp(-3.4 * dt) until it drops under SNAP_SPEED (150 px/s), where the thumb eases onto the nearest tick. The bubble above tilts by v * 0.014 degrees up to 16, and the thumb is a role="slider" with arrow, PageUp, PageDown, Home and End keys. Item 07 covered 15.1% of the frame at an average intensity of 98.5 across 9/23 measured frames, low because the thumb sits still through the settle stretches.

Rail Scrubbing with Inertia and Snap
Open in a new tab

07 · Rail Scrubbing with Inertia and Snap — Measured area 15.103% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

08Next-Only Cursor Pager with a Trail

With no known total only a next button remains, and every new page adds a dot to a chain of visited pages that you can click to go back. cursorOf(n) makes a deterministic four-character hex cursor per page, and the dot gap shrinks to min(36, 232 / (dots - 1)) so the chain keeps its 232-pixel length while each new dot pops in on a spring. Item 08 covered 14.5% of the frame at an average intensity of 72.4 across 18/23 measured frames.

Next-Only Cursor Pager with a Trail
Open in a new tab

08 · Next-Only Cursor Pager with a Trail — Measured area 14.542% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

09Load More with a Remaining-Count Ring

The ring around the button fills with the share already loaded, 12 of 48 at the start, while the number inside counts down the items still to come as 48 - loaded. The fill rides a spring with RING_STIFFNESS 220 and RING_DAMPING 15, the number rolls toward its target at 12 per second, and at zero the number becomes a check mark and the button reads done. Item 09 covered 11.6% of the frame at an average intensity of 113.3 across 23/23 measured frames.

Load More with a Remaining-Count Ring
Open in a new tab

09 · Load More with a Remaining-Count Ring — Measured area 11.583% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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();

Where the Values Change the Scene — Window Size, Spring Stiffness, Jump Delay

Three groups of constants decide most of how a row looks and how a move feels, and the figures in this table were measured over 23 rendered frames per item.

Item Cumulative area % Average intensity Moving frames
01 Fixed slot window 5.6% 71.5 20/23
02 Spring pill 7.7% 180.0 21/23
03 Direction swap 19.4% 43.0 19/23
04 Number trail 14.0% 160.1 22/23
05 Edge rubber band 8.2% 75.7 22/23
06 Dot to bar 14.1% 70.0 22/23
07 Inertia rail 15.1% 98.5 9/23
08 Cursor trail 14.5% 72.4 18/23
09 Ring load more 11.6% 113.3 23/23

Items 01, 02 and 05 stay under 10% because a few numbers and one pill are all that move, which is normal for a small component. The 9/23 for 07 comes from the settle stretches where the thumb is still, and while it is being dragged it moves every frame.

Slot Count Sets the Row Width

The slots in 01 are flex: 0 1 32px with min-width: 0, so even nine of them shrink to fit one line on a 320-pixel phone. An ellipsis slot always hides at least two pages, and pressing it jumps to the middle of the hidden range, round((before + after) / 2).

Damping Ratio Sets the Overshoot

Item 02 runs PILL_STIFFNESS 320 with PILL_DAMPING 17, a damping ratio of about 0.48 and a theoretical overshoot of about 18%. Item 09 has a ratio of about 0.51 and an overshoot of about 16%, and the end buttons in 05 use stiffness 260 and damping 15, a ratio of about 0.47.

Jump Delay Sets the Duration

The total time in 04 is |end - start| * 11 + 270 milliseconds, so page 1 to page 40 takes about 0.7 seconds while a one-page move is nearly instant. Item 03 behaves similarly: the larger the jump, up to the cap, the longer the transition, from 290 ms for one page to 560 ms.

Where It Breaks — Page Count Spikes, Rapid Clicks, Narrow Screens

Slot Counts Below Five

Under 5 slots in 01, inner falls to 0 or less and the current page drops out of the window, and an even count makes the middle part lopsided. Keep the slot count odd and at least 5, since the calculation assumes it.

Jumps Shorter Than Eight Pages

When the distance in 04 is under 8 pages, nine chips cannot all get different numbers. Going from 1 to 3 puts 1 on two chips, 2 on four chips and 3 on three chips because of Math.round, so for short moves turn the trail off or use fewer chips.

Long Jumps and Finger Targets

After a long jump in 06 the dots in the new area climb up from transparent, so the strip can look empty for a frame or two, and the dots are not touch targets, so only previous and next move you. The ticks in 07 sit about 10.4 pixels apart, closer than the 20-pixel thumb, which makes a neighbouring page hard to pick by finger and is why the arrow keys are there. In 08, END_AT only imitates a server saying there is no more, and a real cursor API has to store the earlier cursors itself to let you go back. Two more traps are in 03, where a second press mid-transition makes the middle scene jump once and a large card with filter: blur drops frames on weak devices. The full markup and styles of all nine sit in the download, and the password that unlocks it is sj7cvb6m.

Three Variants and Accessibility

Item Constant Value What changes
01 SLOT_COUNT 7 → 9 With nine slots the middle window grows to five numbers, so page 17 shows 15 to 19, and the stretch near page 1 with no ellipsis reaches page 7. The row has the same slot count on every page.
02 PILL_STIFFNESS 320 → 160 Halving the stiffness raises the damping ratio from about 0.48 to about 0.67, so the theoretical overshoot falls from about 18% to about 6% and the pill takes longer to reach the number.
04 STAGGER_STEP 11 → 22 Doubling the step stretches the page 1 to 40 trail from about 0.7 s to about 1.1 s and lights each number more distinctly.

Items 01 to 06 and 08 wrap their buttons in a nav with an aria-label, while 07 is a role="slider" thumb and 09 is a lone button with no nav. Items 01, 02, 04 and 08 mark the current page with aria-current="page", and all nine keep a hidden aria-live="polite" line that reads the current page out of the total. When prefers-reduced-motion is reduce, the scripted run is switched off and the final state is drawn at once: 01 sits on page 17, the pill in 02 arrives immediately and the cards in 03 swap at once. 04 shows page 40 straight away, 05 does not stretch at the edge, 06 stays on page 17 without blending, 07 holds page 9 and snaps on release, and 08 and 09 update without pops or interpolation. The drag in 07 uses pointer events, so a mouse and a finger take the same path.

How Many Page Numbers Should a Pagination Design Show?

This set defaults to seven slots and 01 stays correct at nine when the width allows it. What matters in a pagination design is not the exact count but that every page shows the same count, so choose the number of visible slots from the screen width backwards.

When a number row is not the right move, the neighbouring posts cover the alternatives. For lists that never end there is Infinite Scroll Loading: 9 Essential UI Patterns, and the drag-down refresh gesture is in Pull to Refresh CSS: 9 Loading Patterns. Tables whose rows open in place are in Collapsible Table Row: 9 UI Patterns, and a comment list that is cut into pages is in Comment Section UI: 9 Thread List Patterns.

Frequently Asked Questions Before You Use Pagination

Why Fix the Slot Count in a Pagination Row?

The row takes the same width on every page, so the buttons do not move while someone pages through, and a finger resting on the screen can hit the next number where it was. The single function windowSlots in 01 is enough, and the slot count is one constant, SLOT_COUNT.

Can a Server Cursor Use the Same Component?

Without a known page total, build it as 08 does, with one next button and dots for the pages already visited. The demo's END_AT is a stand-in for a server end signal, and a real cursor API has to keep the cursors you came from.

When Should I Pick Load More Over a Number Row?

If people need to return to a given page, a number row fits, and if they read a list through to the end, a load-more button like 09 fits. It shows how much is left with the ring and the number, but it cannot jump to a page you name.

Enter the archive password

The password is inside this article. You will find it as you read.