20 CSS Pagination13 / 20

Light JSMIT licensed

Cursor-Based Load More Pagination

Offset pagination breaks on live data: insert a row while someone is reading and page two silently repeats or skips records. Cursor paging fixes it by pointing at the last row you saw, and the UI has to change too — no page count, no jump-to-40, just Load newer above the feed and Load older below, with each row exposing its own cursor id.

Published

Live Demo
Try it

The code

<section class="pgn-13" aria-label="Cursor-Based Load Newer / Older Pagination demo">
  <div class="pgn-13__stage">
    <header class="pgn-13__head">
      <p class="pgn-13__eyebrow">Event stream</p>
      <h2 class="pgn-13__title">Cursor paging, no offsets</h2>
      <p class="pgn-13__sub">Each request carries the id of the last row you saw. Rows inserted while you read never shift the window — the classic failure of LIMIT and OFFSET.</p>
    </header>

    <button class="pgn-13__load" type="button" data-newer>Load newer</button>

    <ol class="pgn-13__feed" data-feed>
      <li class="pgn-13__item"><span class="pgn-13__dot"></span><div><p class="pgn-13__ev">deploy.succeeded</p><p class="pgn-13__det">web · Alex Chen · 12:41</p></div><code class="pgn-13__cur">evt_8f2a</code></li>
      <li class="pgn-13__item"><span class="pgn-13__dot"></span><div><p class="pgn-13__ev">index.rebuilt</p><p class="pgn-13__det">search · system · 12:38</p></div><code class="pgn-13__cur">evt_8f29</code></li>
      <li class="pgn-13__item"><span class="pgn-13__dot"></span><div><p class="pgn-13__ev">key.rotated</p><p class="pgn-13__det">auth · Priya Sharma · 12:30</p></div><code class="pgn-13__cur">evt_8f28</code></li>
    </ol>

    <button class="pgn-13__load" type="button" data-older>Load older</button>

    <p class="pgn-13__cursorline" role="status" aria-live="polite" data-status>Cursor: after=evt_8f28 · limit=3</p>
  </div>
</section>
.pgn-13 {
  --bg: #fafafa;
  --surface: #ffffff;
  --surface-2: #f4f4f5;
  --text: #09090b;
  --muted: #71717a;
  --line: #e4e4e7;
  --acc: #18181b;
  --acc-fg: #ffffff;
  --ring: #6366f1;
  --live: #16a34a;
}

@supports (color: oklch(50% 0 0)) {
  .pgn-13 {
    --bg: oklch(98.4% .002 286);
    --surface: oklch(100% 0 0);
    --surface-2: oklch(96.5% .003 286);
    --text: oklch(17% .006 286);
    --muted: oklch(55% .015 286);
    --line: oklch(92% .004 286);
    --acc: oklch(22% .008 286);
    --acc-fg: oklch(99% 0 0);
    --ring: oklch(62% .19 277);
    --live: oklch(60% .16 150);
  }
}

@media (prefers-color-scheme: dark) {
  .pgn-13:not([data-theme="light"]) {
    --bg: #09090b;
    --surface: #131316;
    --surface-2: #1c1c20;
    --text: #fafafa;
    --muted: #a1a1aa;
    --line: #27272a;
    --acc: #fafafa;
    --acc-fg: #09090b;
    --ring: #818cf8;
    --live: #4ade80;
  }
}

.pgn-13[data-theme="dark"] {
  --bg: #09090b;
  --surface: #131316;
  --surface-2: #1c1c20;
  --text: #fafafa;
  --muted: #a1a1aa;
  --line: #27272a;
  --acc: #fafafa;
  --acc-fg: #09090b;
  --ring: #818cf8;
  --live: #4ade80;
}

.pgn-13 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.5rem,5vw,4rem);
  background: var(--bg);
  color: var(--text);
  font-family: system-ui,-apple-system,"Segoe UI",sans-serif;
  -webkit-font-smoothing: antialiased;
}

.pgn-13,
.pgn-13 *,
.pgn-13 *::before,
.pgn-13 *::after {
  box-sizing: border-box;
}

@supports (interpolate-size: allow-keywords) {
  .pgn-13 {
    interpolate-size: allow-keywords;
  }
}

.pgn-13__stage {
  width: 100%;
  max-width: 42rem;
  display: grid;
  gap: .85rem;
  justify-items: stretch;
}

.pgn-13__head {
  display: grid;
  gap: .45rem;
  margin-bottom: .75rem;
}

.pgn-13__eyebrow {
  margin: 0;
  font-size: .6875rem;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--muted);
}

.pgn-13__title {
  margin: 0;
  font-size: clamp(1.4rem,3.2vw,1.9rem);
  font-weight: 600;
  letter-spacing: -.025em;
}

.pgn-13__sub {
  margin: 0;
  max-width: 34rem;
  font-size: .875rem;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}

.pgn-13__load {
  height: 2.75rem;
  border: 1px dashed var(--line);
  border-radius: .75rem;
  background: transparent;
  color: var(--muted);
  font: inherit;
  font-size: .8125rem;
  cursor: pointer;
  transition: background .16s ease, color .16s ease, border-color .16s ease;
}

.pgn-13__load:not(:disabled):hover {
  background: var(--surface-2);
  color: var(--text);
  border-color: color-mix(in oklab,var(--text) 25%,var(--line));
}

.pgn-13__load:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

.pgn-13__load:disabled {
  opacity: .4;
  cursor: not-allowed;
}

.pgn-13__feed {
  display: grid;
  gap: .5rem;
  list-style: none;
  margin: 0;
  padding: 0;
  block-size: auto;
  transition: block-size .3s cubic-bezier(.16,1,.3,1);
}

.pgn-13__item {
  display: flex;
  align-items: center;
  gap: .85rem;
  padding: .9rem 1rem;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: .75rem;
  animation: pgn-13-in .3s cubic-bezier(.16,1,.3,1);
}

.pgn-13__item > div {
  flex: 1 1 auto;
  min-width: 0;
}

@keyframes pgn-13-in {
  from {
    opacity: 0;
    translate: 0 .4rem;
  }
}

.pgn-13__dot {
  width: .5rem;
  height: .5rem;
  border-radius: 50%;
  background: var(--live);
  flex: none;
}

.pgn-13__ev {
  margin: 0;
  font-size: .875rem;
  font-weight: 550;
  font-family: ui-monospace,SFMono-Regular,Menlo,monospace;
  letter-spacing: -.01em;
}

.pgn-13__det {
  margin: .15rem 0 0;
  font-size: .75rem;
  color: var(--muted);
}

.pgn-13__cur {
  font-family: ui-monospace,SFMono-Regular,Menlo,monospace;
  font-size: .6875rem;
  color: var(--muted);
  background: var(--surface-2);
  padding: .2rem .4rem;
  border-radius: .3rem;
  flex: none;
}

.pgn-13__cursorline {
  margin: .5rem 0 0;
  font-family: ui-monospace,SFMono-Regular,Menlo,monospace;
  font-size: .75rem;
  color: var(--muted);
  text-align: center;
}

@media (prefers-reduced-motion: reduce) {
  .pgn-13 * {
    animation: none !important;
    transition: none !important;
  }
}
(() => {
  const root = document.querySelector('.pgn-13');
  if (!root) return;
  const feed = root.querySelector('[data-feed]');
  const status = root.querySelector('[data-status]');
  const newer = root.querySelector('[data-newer]');
  const older = root.querySelector('[data-older]');
  const pages = [
    [['deploy.succeeded', 'web · Alex Chen · 12:41', '8f2a'], ['index.rebuilt', 'search · system · 12:38', '8f29'], ['key.rotated', 'auth · Priya Sharma · 12:30', '8f28']],
    [['webhook.retried', 'billing · system · 12:12', '8f27'], ['plan.upgraded', 'billing · Jordan Lee · 11:58', '8f26'], ['seat.invited', 'team · Sam Rivera · 11:47', '8f25']],
    [['export.finished', 'reports · Priya Sharma · 11:20', '8f24'], ['rule.disabled', 'alerts · Alex Chen · 11:04', '8f23'], ['login.blocked', 'auth · system · 10:52', '8f22']]
  ];
  let i = 0;
  const paint = () => {
    feed.replaceChildren(...pages[i].map(([ev, det, id]) => {
      const li = document.createElement('li');
      li.className = 'pgn-13__item';
      li.innerHTML = '<span class="pgn-13__dot"></span><div><p class="pgn-13__ev"></p><p class="pgn-13__det"></p></div><code class="pgn-13__cur"></code>';
      li.querySelector('.pgn-13__ev').textContent = ev;
      li.querySelector('.pgn-13__det').textContent = det;
      li.querySelector('.pgn-13__cur').textContent = 'evt_' + id;
      return li;
    }));
    newer.disabled = i === 0; older.disabled = i === pages.length - 1;
    status.textContent = 'Cursor: after=evt_' + pages[i].at(-1)[2] + ' · limit=3';
  };
  newer.addEventListener('click', () => { if (i > 0) { i--; paint(); } });
  older.addEventListener('click', () => { if (i < pages.length - 1) { i++; paint(); } });
  paint();
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Pagination from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Cursor-Based Load More Pagination
Source: https://codefronts.com/navigation/css-pagination/cursor-based-load-more-pagination/

Offset pagination breaks on live data: insert a row while someone is reading and page two silently repeats or skips records. Cursor paging fixes it by pointing at the last row you saw, and the UI has to change too — no page count, no jump-to-40, just Load newer above the feed and Load older below, with each row exposing its own cursor id.
## HTML
```html
<section class="pgn-13" aria-label="Cursor-Based Load Newer / Older Pagination demo">
  <div class="pgn-13__stage">
    <header class="pgn-13__head">
      <p class="pgn-13__eyebrow">Event stream</p>
      <h2 class="pgn-13__title">Cursor paging, no offsets</h2>
      <p class="pgn-13__sub">Each request carries the id of the last row you saw. Rows inserted while you read never shift the window — the classic failure of LIMIT and OFFSET.</p>
    </header>

    <button class="pgn-13__load" type="button" data-newer>Load newer</button>

    <ol class="pgn-13__feed" data-feed>
      <li class="pgn-13__item"><span class="pgn-13__dot"></span><div><p class="pgn-13__ev">deploy.succeeded</p><p class="pgn-13__det">web · Alex Chen · 12:41</p></div><code class="pgn-13__cur">evt_8f2a</code></li>
      <li class="pgn-13__item"><span class="pgn-13__dot"></span><div><p class="pgn-13__ev">index.rebuilt</p><p class="pgn-13__det">search · system · 12:38</p></div><code class="pgn-13__cur">evt_8f29</code></li>
      <li class="pgn-13__item"><span class="pgn-13__dot"></span><div><p class="pgn-13__ev">key.rotated</p><p class="pgn-13__det">auth · Priya Sharma · 12:30</p></div><code class="pgn-13__cur">evt_8f28</code></li>
    </ol>

    <button class="pgn-13__load" type="button" data-older>Load older</button>

    <p class="pgn-13__cursorline" role="status" aria-live="polite" data-status>Cursor: after=evt_8f28 · limit=3</p>
  </div>
</section>
```
## CSS
```css
.pgn-13 {
  --bg: #fafafa;
  --surface: #ffffff;
  --surface-2: #f4f4f5;
  --text: #09090b;
  --muted: #71717a;
  --line: #e4e4e7;
  --acc: #18181b;
  --acc-fg: #ffffff;
  --ring: #6366f1;
  --live: #16a34a;
}

@supports (color: oklch(50% 0 0)) {
  .pgn-13 {
    --bg: oklch(98.4% .002 286);
    --surface: oklch(100% 0 0);
    --surface-2: oklch(96.5% .003 286);
    --text: oklch(17% .006 286);
    --muted: oklch(55% .015 286);
    --line: oklch(92% .004 286);
    --acc: oklch(22% .008 286);
    --acc-fg: oklch(99% 0 0);
    --ring: oklch(62% .19 277);
    --live: oklch(60% .16 150);
  }
}

@media (prefers-color-scheme: dark) {
  .pgn-13:not([data-theme="light"]) {
    --bg: #09090b;
    --surface: #131316;
    --surface-2: #1c1c20;
    --text: #fafafa;
    --muted: #a1a1aa;
    --line: #27272a;
    --acc: #fafafa;
    --acc-fg: #09090b;
    --ring: #818cf8;
    --live: #4ade80;
  }
}

.pgn-13[data-theme="dark"] {
  --bg: #09090b;
  --surface: #131316;
  --surface-2: #1c1c20;
  --text: #fafafa;
  --muted: #a1a1aa;
  --line: #27272a;
  --acc: #fafafa;
  --acc-fg: #09090b;
  --ring: #818cf8;
  --live: #4ade80;
}

.pgn-13 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.5rem,5vw,4rem);
  background: var(--bg);
  color: var(--text);
  font-family: system-ui,-apple-system,"Segoe UI",sans-serif;
  -webkit-font-smoothing: antialiased;
}

.pgn-13,
.pgn-13 *,
.pgn-13 *::before,
.pgn-13 *::after {
  box-sizing: border-box;
}

@supports (interpolate-size: allow-keywords) {
  .pgn-13 {
    interpolate-size: allow-keywords;
  }
}

.pgn-13__stage {
  width: 100%;
  max-width: 42rem;
  display: grid;
  gap: .85rem;
  justify-items: stretch;
}

.pgn-13__head {
  display: grid;
  gap: .45rem;
  margin-bottom: .75rem;
}

.pgn-13__eyebrow {
  margin: 0;
  font-size: .6875rem;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--muted);
}

.pgn-13__title {
  margin: 0;
  font-size: clamp(1.4rem,3.2vw,1.9rem);
  font-weight: 600;
  letter-spacing: -.025em;
}

.pgn-13__sub {
  margin: 0;
  max-width: 34rem;
  font-size: .875rem;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}

.pgn-13__load {
  height: 2.75rem;
  border: 1px dashed var(--line);
  border-radius: .75rem;
  background: transparent;
  color: var(--muted);
  font: inherit;
  font-size: .8125rem;
  cursor: pointer;
  transition: background .16s ease, color .16s ease, border-color .16s ease;
}

.pgn-13__load:not(:disabled):hover {
  background: var(--surface-2);
  color: var(--text);
  border-color: color-mix(in oklab,var(--text) 25%,var(--line));
}

.pgn-13__load:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

.pgn-13__load:disabled {
  opacity: .4;
  cursor: not-allowed;
}

.pgn-13__feed {
  display: grid;
  gap: .5rem;
  list-style: none;
  margin: 0;
  padding: 0;
  block-size: auto;
  transition: block-size .3s cubic-bezier(.16,1,.3,1);
}

.pgn-13__item {
  display: flex;
  align-items: center;
  gap: .85rem;
  padding: .9rem 1rem;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: .75rem;
  animation: pgn-13-in .3s cubic-bezier(.16,1,.3,1);
}

.pgn-13__item > div {
  flex: 1 1 auto;
  min-width: 0;
}

@keyframes pgn-13-in {
  from {
    opacity: 0;
    translate: 0 .4rem;
  }
}

.pgn-13__dot {
  width: .5rem;
  height: .5rem;
  border-radius: 50%;
  background: var(--live);
  flex: none;
}

.pgn-13__ev {
  margin: 0;
  font-size: .875rem;
  font-weight: 550;
  font-family: ui-monospace,SFMono-Regular,Menlo,monospace;
  letter-spacing: -.01em;
}

.pgn-13__det {
  margin: .15rem 0 0;
  font-size: .75rem;
  color: var(--muted);
}

.pgn-13__cur {
  font-family: ui-monospace,SFMono-Regular,Menlo,monospace;
  font-size: .6875rem;
  color: var(--muted);
  background: var(--surface-2);
  padding: .2rem .4rem;
  border-radius: .3rem;
  flex: none;
}

.pgn-13__cursorline {
  margin: .5rem 0 0;
  font-family: ui-monospace,SFMono-Regular,Menlo,monospace;
  font-size: .75rem;
  color: var(--muted);
  text-align: center;
}

@media (prefers-reduced-motion: reduce) {
  .pgn-13 * {
    animation: none !important;
    transition: none !important;
  }
}
```

## JavaScript
```js
(() => {
  const root = document.querySelector('.pgn-13');
  if (!root) return;
  const feed = root.querySelector('[data-feed]');
  const status = root.querySelector('[data-status]');
  const newer = root.querySelector('[data-newer]');
  const older = root.querySelector('[data-older]');
  const pages = [
    [['deploy.succeeded', 'web · Alex Chen · 12:41', '8f2a'], ['index.rebuilt', 'search · system · 12:38', '8f29'], ['key.rotated', 'auth · Priya Sharma · 12:30', '8f28']],
    [['webhook.retried', 'billing · system · 12:12', '8f27'], ['plan.upgraded', 'billing · Jordan Lee · 11:58', '8f26'], ['seat.invited', 'team · Sam Rivera · 11:47', '8f25']],
    [['export.finished', 'reports · Priya Sharma · 11:20', '8f24'], ['rule.disabled', 'alerts · Alex Chen · 11:04', '8f23'], ['login.blocked', 'auth · system · 10:52', '8f22']]
  ];
  let i = 0;
  const paint = () => {
    feed.replaceChildren(...pages[i].map(([ev, det, id]) => {
      const li = document.createElement('li');
      li.className = 'pgn-13__item';
      li.innerHTML = '<span class="pgn-13__dot"></span><div><p class="pgn-13__ev"></p><p class="pgn-13__det"></p></div><code class="pgn-13__cur"></code>';
      li.querySelector('.pgn-13__ev').textContent = ev;
      li.querySelector('.pgn-13__det').textContent = det;
      li.querySelector('.pgn-13__cur').textContent = 'evt_' + id;
      return li;
    }));
    newer.disabled = i === 0; older.disabled = i === pages.length - 1;
    status.textContent = 'Cursor: after=evt_' + pages[i].at(-1)[2] + ' · limit=3';
  };
  newer.addEventListener('click', () => { if (i > 0) { i--; paint(); } });
  older.addEventListener('click', () => { if (i < pages.length - 1) { i++; paint(); } });
  paint();
})();
```

How this works

The query is WHERE id < cursor LIMIT n rather than LIMIT n OFFSET m, so there is nothing to count and no page number to render. The status line prints the request the next click would make:

status.textContent =
  'Cursor: after=evt_' + pages[i].at(-1)[2] + ' · limit=3';

Batches animate in with a short translate/opacity keyframe, and the list height transitions between batch sizes using the new keyword-interpolation primitive, gated so it degrades cleanly:

@supports (interpolate-size: allow-keywords) {
  .pgn-13 { interpolate-size: allow-keywords; }
}
.pgn-13__feed { block-size: auto; transition: block-size .3s; }

Make it yours

  • Replace the pages array with a fetch that sends the last row's cursor, and append rather than replace for an endless feed.
  • Move both buttons below the list if your feed is chronological rather than reverse-chronological.
  • The green dot is a per-row liveness cue — swap it for a severity colour scale keyed off the event type.
  • Remove the @supports block and the height simply snaps instead of easing; nothing else changes.

Gotchas — read before shipping

  • Cursor paging cannot render "Page X of N" because it never counts the collection. Do not bolt a total onto it.
  • The cursor column must be stable, unique and monotonic. A timestamp with duplicates silently skips or repeats rows at the boundary.
  • replaceChildren discards focus — move focus to the first new row after a keyboard-initiated load.
  • interpolate-size and calc-size() are Chrome-only for now, hence the gate.
  • The innerHTML call here only ever writes a fixed template; never pass API strings through it.

Browser support

ChromeSafariFirefoxEdge
129+14+78+129+

The component works everywhere; interpolate-size: allow-keywords (Chrome 129) is the only progressive enhancement and is behind @supports. Without it the feed height changes instantly instead of easing.

Techniques used in this demo

Search CodeFronts

Loading…