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

Infinite Scroll Implementation — 9 List UI Basics

Among nine infinite scroll implementation ideas, one slides in new rows with no spinner, one swaps row order on sort click, and one shadows a frozen column

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

These nine start with the two feeds that only ever add rows — appending and skeleton loading — then move through the row-level UI decisions (pull-to-refresh, emptying out, bulk selection, swipe actions) before ending on three whole-table concerns: sortable rows, a frozen column, and pagination. Every demo below runs on invented data — no real names, no fetch, no Math.random — so what you're watching is the interaction logic itself, not network timing. A note on what "implementation" means here: this is the client half of an infinite scroll example — the observing, batching, and entrance logic that decides when rows arrive and how they land — not the server endpoint behind it, which stays out of scope on purpose so each file pastes and runs with zero setup. If you've already read the swipe-pattern and pull-to-refresh posts this one links to at the bottom, treat this page as the base layer those two build on: the rows themselves, before any gesture gets attached to them.

Demo

The 3×3 grid above is wired to the same nine builds this article walks through — try the swipe on 06 or drag the pull-to-refresh zone on 03 before reading the code, since watching a value clamp in real time explains it faster than a sentence can.

Anatomy of nine patterns — where the one layer of calculation runs

None of these nine patterns is CSS alone. Each one carries exactly one small piece of arithmetic in JavaScript — not a physics engine, just a ratio, a distance, or a saved value — and everything visual downstream reads from it. That's a different claim than a typical infinite scroll example you'd skim on a code-sharing site, which usually shows only the fetch-and-append loop and leaves the batch-size or timing math as an exercise. The nine group into three shapes by what that one layer actually measures.

Item Covered area Avg intensity Frames moved (of 23)
01 Infinite scroll append 35.55% 44.6 23
02 Skeleton rows 8.96% 42.7 12
03 Pull-to-refresh sweep 29.58% 43.5 12
04 Empty-list message 46.35% 47.9 21
05 Bulk select badge 1.73% 45.1 9
06 Swipe action 2.77% 83.9 16
07 Sortable table 2.23% 121.3 9
08 Sticky column shadow 4.62% 139.7 23
09 Pagination dot 1.67% 96.7 16

04's empty-state fade covers the most screen of the nine (46.35%) because a whole panel grows in; 09's sliding dot covers the least (1.67%) because only a small pill moves. 08's shadow has the highest average intensity of the nine (139.7) even though its own footprint is small — a shadow ramping from nothing to its cap in forty pixels of scroll is a sharp per-pixel change, not a wide one. 02's skeleton bars sit at the opposite end, the lowest average intensity of the nine (42.7), since a shimmer sweep is a gentle, gradual change over a wide area. 01 and 08 both move on all 23 of 23 measured frames — one because the list keeps growing under a held scroll, the other because the shadow recalculates on every scroll tick — while 05 and 07 move on only 9 of 23, since a badge pop and a FLIP reorder both fire once and settle.

Real geometry, not a guessed number

02, 07, and 09 all read a real measurement out of the browser instead of assuming one. 02's skeleton bars come from a hidden <canvas>'s measureText() call against the row's actual font; 07's FLIP reorder reads getBoundingClientRect().top before and after the sort; 09's sliding dot reads each dot's real offsetLeft instead of trusting a fixed spacing value.

A continuous ratio turned into a UI magnitude

01, 03, and 08 each take a live 0–1 value and remap it into something visible. 01 turns IntersectionObserver's intersectionRatio into a row-batch count; 03 pushes a raw pull distance through an exponential decay curve before it becomes a displayed pull amount; 08 turns scrollLeft into a shadow alpha once it's normalized against a fixed ramp distance.

State carried from the previous call

04, 05, and 06 all remember something from the call before. 04's empty-state panel lerps from its previous height toward a new target every frame; 05's shift-click reuses the last-handled checkbox index to fill a whole range; 06 computes a release velocity from the pointer position and timestamp it saw two events ago.

Code per pattern

The nine code blocks below are the exact contiguous lines from each demo's index.html — same variable names, same order, only the Korean comments and on-screen strings translated into English.

01Infinite Scroll That Appends Without a Spinner

When scroll nears the bottom, the next batch of rows slides into place at the list's end one after another, with no spinner or skeleton feedback at all — a different demo from post 349's loading-feedback-focused infinite scroll, which is built around what happens while data is in flight rather than the rows' own entrance. Here the batch size itself is a live number: IntersectionObserver reports how far the sentinel has entered the viewport, and that ratio is rounded into a row count between MIN_BATCH and MAX_BATCH.

Infinite Scroll That Appends Without a Spinner
Open in a new tab

01 · Infinite Scroll That Appends Without a Spinner — Measured area 35.554% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

IntersectionObserveraddRowscrollTop
  // Calculation layer: the larger the ratio of the sentinel overlapping the viewport (intersectionRatio), the more rows get appended at once —
  // barely touching the edge appends just a few, scrolling deep in appends a big batch at once.
  var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (entry.isIntersecting && !loading && rowSeq < MAX_ROWS) {
        loading = true;
        var batch = Math.round(MIN_BATCH + (MAX_BATCH - MIN_BATCH) * clamp(entry.intersectionRatio, 0, 1));
        for (var j = 0; j < batch; j += 1) { addRow(j * SCROLL_APPEND_STAGGER_MS); }
        window.setTimeout(function () { loading = false; }, LOAD_COOLDOWN_MS);
      }
    });
  }, { root: view, threshold: [0, 0.2, 0.4, 0.6, 0.8, 1] });
  io.observe(sentinel);

02Skeleton Rows for the First List Load

Before any data arrives, the whole screen fills with gray bar-shaped skeleton rows, then swaps out all at once the instant real data lands — a different demo from post 349's append-time skeleton during scroll, since this one is a single, one-time full-list swap on first entry rather than a per-row loading state. Each bar's width isn't guessed: a hidden <canvas> measures how wide the real text will render at the row's font, so the skeleton already hints at how long the eventual line is.

Skeleton Rows for the First List Load
Open in a new tab

02 · Skeleton Rows for the First List Load — Measured area 8.956% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

measureBarWidthssetLoadedanimation-fill-mode
  // Calculation layer: measures the canvas width of the text each row will actually show once loaded,
  // and sets the skeleton bar's width to that ratio — not a fixed width, but one that reflects content length in advance.
  function measureBarWidths() {
    var canvas = document.createElement('canvas');
    var ctx = canvas.getContext('2d');
    ctx.font = '600 13px -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
    var widths = ROWS.map(function (text) { return ctx.measureText(text).width; });
    var maxW = Math.max.apply(null, widths);
    return widths.map(function (w) {
      var pct = (w / maxW) * 100;
      return Math.max(MIN_BAR_PCT, Math.round(pct));
    });
  }

03Pull-to-Refresh That Sweeps Only the Rows That Changed

After the pull-to-refresh gesture completes, instead of redrawing the whole list, only the rows whose values changed get a brief highlight sweep showing what was updated — a different focus from post 353, which covers the pull gesture, icon, and elastic feel itself rather than what happens after release. computeChanged() diffs the old and new value arrays first, so untouched rows never re-render at all.

Pull-to-Refresh That Sweeps Only the Rows That Changed
Open in a new tab

03 · Pull-to-Refresh That Sweeps Only the Rows That Changed — Measured area 29.576% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

applyPullsweepRowcomputeChanged
  function sweepRow(row, delayMs) {
    row.classList.remove('is-swept');
    void row.offsetWidth; // Force reflow — so the animation restarts from the beginning even if the class is re-added
    row.style.animationDelay = delayMs + 'ms';
    row.classList.add('is-swept');
  }

  function refresh() {
    var next = currentIsA ? SET_B : SET_A;
    var changed = computeChanged(values, next);
    values = next.slice();
    currentIsA = !currentIsA;
    renderValues();
    hint.textContent = changed.length ? 'Sweeping only the rows that changed' : 'No rows changed';
    changed.forEach(function (idx, order) {
      sweepRow(rows[idx], order * SWEEP_STAGGER_MS);
    });
  }

04Empty-List Message That Appears When the Last Row Is Gone

The instant the last remaining row is removed, a small icon and a single line of guidance text fade into the now-empty list area — the plainest version of a list announcing its own emptiness, with no illustration at all. A requestAnimationFrame loop lerps the empty-state panel's height toward its target every frame, which is also the only one of these nine patterns driven by rAF instead of a CSS transition, animation, or setTimeout.

Empty-List Message That Appears When the Last Row Is Gone
Open in a new tab

04 · Empty-List Message That Appears When the Last Row Is Gone — Measured area 46.348% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

computeTargetremoveRowtransform-origin
  function computeTarget() {
    return list.children.length === 0 ? EMPTY_HEIGHT : 0;
  }

  function step() {
    var diff = targetHeight - currentHeight;
    if (Math.abs(diff) < 0.5) {
      currentHeight = targetHeight;
      empty.style.height = currentHeight + 'px';
      empty.style.opacity = (currentHeight / EMPTY_HEIGHT).toFixed(2);
      rafId = null;
      return;
    }
    currentHeight += diff * LERP_RATE;
    empty.style.height = currentHeight + 'px';
    empty.style.opacity = Math.min(1, currentHeight / EMPTY_HEIGHT).toFixed(2);
    rafId = window.requestAnimationFrame(step);
  }

05Bulk Select That Counts With a Badge, No Toolbar

Each time a checkbox is toggled on, only that row's background tints, and a small badge number in the header above the list updates to the current selection count — a different demo from post 207's bulk-select-checkbox-toolbar, which slides a toolbar up from the bottom, since this one signals selection with nothing but a header badge. Shift-click reuses the last handled row index to check or uncheck an entire range in one pass, the same range-selection arithmetic spreadsheets use.

Bulk Select That Counts With a Badge, No Toolbar
Open in a new tab

05 · Bulk Select That Counts With a Badge, No Toolbar — Measured area 1.725% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

applyRangeupdateBadgearia-checked
  function updateBadge() {
    var n = checkboxes.filter(function (cb) { return cb.checked; }).length;
    badge.hidden = n === 0;
    badge.textContent = n + ' selected';
    badge.classList.remove('is-pop');
    void badge.offsetWidth;
    badge.classList.add('is-pop');
  }

  // Calculation layer: works out the range between the last handled index and this one arithmetically —
  // and sets that whole span to the same state in one pass, so you don't have to click each row.
  function applyRange(fromIdx, toIdx, checked) {
    var lo = Math.min(fromIdx, toIdx);
    var hi = Math.max(fromIdx, toIdx);
    for (var i = lo; i <= hi; i += 1) {
      setChecked(checkboxes[i], checked);
    }
  }

06Basic Swipe Action That Reveals in One Direction

Swiping a row sideways reveals a single hidden icon button up to a fixed width, and releasing either snaps it open to that width or springs it back closed — the plainest form of post 207's two swipe demos, which add a delete-tinted reveal and a stacked multi-button version this one skips. Release doesn't just check where your finger stopped: if the velocity between the last two pointer timestamps crosses a threshold, the swipe's direction alone decides open or closed, and only a slow release falls back to the halfway-position rule.

Basic Swipe Action That Reveals in One Direction
Open in a new tab

06 · Basic Swipe Action That Reveals in One Direction — Measured area 2.771% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

openRowcloseRowcurrentX
    content.addEventListener('pointerup', function () {
      if (!dragging) { return; }
      dragging = false;
      // Calculation layer: if the release velocity crosses the threshold, the direction of that velocity decides
      // open or closed regardless of where it stopped. Below the threshold, the halfway position decides instead.
      var openDecision;
      if (velocity < -VELOCITY_OPEN_THRESHOLD) {
        openDecision = true;
      } else if (velocity > VELOCITY_OPEN_THRESHOLD) {
        openDecision = false;
      } else {
        openDecision = currentX() < -ACTION_WIDTH / 2;
      }
      if (openDecision) { openRow(); } else { closeRow(); }
    });

    delBtn.tabIndex = -1;
    delBtn.addEventListener('click', function () {
      human();
      row.classList.add('is-deleted');
    });

07Table Whose Rows Physically Swap Places When Sorted

Clicking a column header doesn't just relist the values — each row animates from its old spot to its new sorted position along real coordinates, physically swapping places, isolating just that row-position movement from post 257's multi-column sort and post 362's insert/delete transitions. It's a textbook FLIP: First measures every row's position, the sort actually reorders the DOM (Last), and the gap between those two measurements (Invert) is played back as a transform that eases to zero (Play).

Table Whose Rows Physically Swap Places When Sorted
Open in a new tab

07 · Table Whose Rows Physically Swap Places When Sorted — Measured area 2.227% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

sortByupdateHeadersaria-sort
    var rows = getRows();
    var first = {};
    rows.forEach(function (row) { first[row.dataset.id] = row.getBoundingClientRect().top; });

    rows.sort(comparator(key, dir));
    rows.forEach(function (row) { tbody.appendChild(row); });

    rows.forEach(function (row) {
      var last = row.getBoundingClientRect().top;
      var delta = first[row.dataset.id] - last;
      if (delta) {
        row.style.transition = 'none';
        row.style.transform = 'translateY(' + delta + 'px)';
        void row.offsetHeight;
        row.style.transition = '';
        row.style.transform = 'translateY(0)';
      }
    });

08Frozen Column That Carries Its Shadow While Scrolling Sideways

Scrolling the table sideways leaves the leftmost column frozen in place while the rest pass beneath it, and a soft shadow along the frozen column's right edge deepens in proportion to how far the scroll has moved — a different axis from post 207's sticky-columns-header-freeze, which freezes the top header on vertical scroll instead of a left column on horizontal scroll. That shadow also carries this article's highest average pixel intensity of the nine, 139.7, because its alpha ramps from nothing to its cap in just 40 pixels of scrollLeft.

Frozen Column That Carries Its Shadow While Scrolling Sideways
Open in a new tab

08 · Frozen Column That Carries Its Shadow While Scrolling Sideways — Measured area 4.625% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

updateShadowscrollLeftposition:sticky
  // Calculation layer: normalizes the horizontal scroll amount (scrollLeft) against SHADOW_RAMP_PX to compute the shadow alpha arithmetically —
  // no scroll means no shadow, and even a small nudge pushes it close to the ceiling almost immediately.
  function updateShadow() {
    var raw = clamp(view.scrollLeft / SHADOW_RAMP_PX, 0, 1);
    var alpha = raw * STICKY_COL_SHADOW_ALPHA;
    view.style.setProperty('--shadow-alpha', alpha.toFixed(3));
  }
  view.addEventListener('scroll', updateShadow, { passive: true });
  updateShadow();

09Pagination Where One Dot Slides, No Ellipsis

Behind the row of dots between the prev and next arrows, a filled pill the same size and shape as a dot slides over and settles onto whichever dot marks the current page — the simplest form of post 207's pagination-numbered-ellipsis, built for a handful of pages rather than many. The indicator never assumes even spacing: measureAndMove() reads each dot's real offsetLeft and moves only a transform, so the width of the pill itself never changes.

Pagination Where One Dot Slides, No Ellipsis
Open in a new tab

09 · Pagination Where One Dot Slides, No Ellipsis — Measured area 1.672% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

measureAndMovegoToaria-current
  // Calculation layer: instead of assuming a fixed spacing between dots, it measures the actually rendered coordinate (offsetLeft) directly
  // and moves the sliding indicator to that coordinate — only a transform shifts position, the width never changes.
  function measureAndMove(index) {
    var dot = dots[index];
    track.style.setProperty('--track-x', dot.offsetLeft + 'px');
  }

  function goTo(index) {
    index = clamp(index, 0, total - 1);
    if (index === current) { return; }
    current = index;
    dots.forEach(function (dot, i) {
      if (i === current) { dot.setAttribute('aria-current', 'page'); } else { dot.removeAttribute('aria-current'); }
    });
    measureAndMove(current);
    panel.textContent = PANEL_TEXTS[current];
    prevBtn.disabled = current === 0;
    nextBtn.disabled = current === total - 1;
  }

Where values change the UI — append stagger, sort move duration, sticky shadow

Three of the constants below are the ones this post's variant table changes, but every one of the nine has at least one number worth turning on purpose — getting an infinite scroll ui design right mostly means choosing these values deliberately instead of shipping whatever a first draft happened to have. None of them hides in a config file or behind a framework option; each sits at the top of its own index.html as a plain var declaration, so tuning one is a one-line edit followed by a refresh. They also interact far less than you might expect: the append stagger in 01 has no opinion about the move duration in 07, the shadow ramp in 08 is independent of both, and each demo was measured with the others doing nothing — which is why the table earlier in this article could quote one number per item without some combining effect hiding inside it.

01 — SCROLL_APPEND_STAGGER_MS

Raising this delay (default 40ms) spaces out each newly appended row's entrance; lowering it makes a whole batch look like it lands at once.

02 — MIN_BAR_PCT

This floors how narrow a skeleton bar can get relative to its eventual text width — push it toward 100 and every bar looks the same length before data even proves that's true.

03 — SWEEP_STAGGER_MS

Delays each changed row's highlight sweep relative to the others; at 0 every changed row flashes in the same frame instead of in sequence.

04 — LERP_RATE

Controls how fast the empty-state panel's height chases its target each frame — closer to 1 snaps almost instantly, closer to 0 drifts across many more frames.

05 — the badge's forced reflow

void badge.offsetWidth before re-adding is-pop isn't a duration to tune, but removing it changes the badge's pop from "replays on every selection" to "plays once, ever."

06 — VELOCITY_OPEN_THRESHOLD

Lower this and a slower flick is enough to snap the row open by direction alone; raise it and release position decides more often, even for a fast swipe.

07 — SORT_REORDER_MS

This is the FLIP animation's Play duration (default 250ms) — the variant table below doubles it to 500ms so a reader can actually track which row went where.

08 — STICKY_COL_SHADOW_ALPHA and SHADOW_RAMP_PX

The alpha cap (default 0.35, tested down to 0.15 in the variant table) sets how dark the shadow gets; SHADOW_RAMP_PX (40) sets how little scrolling it takes to reach that cap.

09 — AUTO_STEP_MS

Only governs the demo's self-playing interval between pages — a real pagination component doesn't autoplay, so in production this constant simply doesn't exist.

Where it breaks — accessibility, reduced-motion, keyboard focus

The one real accessibility gap in these nine sits in 06: delBtn.tabIndex starts at -1, and it's only ever set to 0 inside openRow() — meaning a keyboard-only user has no way to Tab onto that delete button at all, since nothing short of a pointer swipe ever calls openRow() in the first place. A mouse or touch user can always reach it; a keyboard user, as shipped, never can. That gap only shows up by reading the full file, too — the kps8kwrz the zip asks for unlocks the complete source behind all nine, not just the excerpted lines above.

What reduced motion leaves behind

  • 01 — the auto-scroll loop and each row's entrance animation stop; IntersectionObserver keeps working, so a real scroll still appends a batch, just without the fade-in.
  • 02 — the shimmer sweep and the loaded-state entrance animation both turn off; the list jumps straight to its loaded content instead of easing into it.
  • 03 — the pull zone's transition and the changed-row highlight sweep are removed; a refresh still swaps in new values, just without any motion marking which ones changed.
  • 04 — a removed row's exit animation is skipped entirely, and the code doesn't wait for an animationend event that would never fire — it removes the row and recomputes the target height immediately.
  • 05 — the badge's pop re-trigger animation turns off; click and shift-click range selection keep working exactly as before.
  • 06 — the swipe's own CSS transition is removed; the demo's autoplay loop also stops, but a person's real swipe still opens and closes at the same velocity threshold.
  • 07 — the table row transition is removed, which quietly disables the Play half of FLIP; the First and Invert position math still runs every time, it just has nothing left to animate.
  • 08 — only the frozen column's own transition is removed; the shadow's alpha still recalculates on every scroll tick, since that's a --shadow-alpha custom property update, not a CSS transition.
  • 09 — the sliding indicator's transition is removed and the autoplay loop stops; clicking a dot or using arrow keys still moves the current page and recalculates offsetLeft the same way.

Three variants and accessibility

Variant Change Feel
Widen the new-row entrance stagger delay 01 SCROLL_APPEND_STAGGER_MS 40 → 90 The delay between newly appended rows stretches from 40ms to 90ms, so rows no longer land in one blurred burst — each one settles in visibly, one at a time. Batch size is untouched, so only the entrance rhythm slows, not the row count.
Extend the row-move animation on sort 07 SORT_REORDER_MS 250 → 500 The time each row takes to travel from its old spot to its new one after a sort click stretches from 250ms to 500ms, making it far easier to visually track which row went where — at the cost of a longer felt wait before the sort settles.
Lower the frozen column's shadow strength 08 STICKY_COL_SHADOW_ALPHA 0.35 → 0.15 The frozen column's right-edge shadow caps out at alpha 0.15 instead of 0.35 once fully scrolled, so the cue that content is scrolled underneath reads much fainter. The table looks flatter than with the darker shadow, but draws less attention to itself.

Three of the nine already carry ARIA worth knowing about: 07's sortable headers use aria-sort so a screen reader announces the current sort direction without watching the row order shift; 09's dots use aria-current="page" on whichever one is active, the same attribute real pagination links use; and 05's checkboxes keep aria-checked synced to their real checked state on every toggle, including rows a shift-click range never directly clicked.

FAQ

Do any of these nine patterns need an infinite scroll codepen or an outside library to run?

No — every one of the nine is vanilla JavaScript and CSS in a single index.html, with no fetch, no external script tag, and no animation library anywhere in the nine folders; the zip is the full nine, not a stripped-down version of what's shown above.

Which of the nine is the safest starting point for a real product?

01's append batching and 05's bulk-select badge are the least opinionated — both are additive UI decisions with no accessibility gap to fix first, unlike 06's swipe action, which needs its tabIndex handling fixed before it ships to keyboard users.

Why does 08's shadow feel stronger than the others even though it barely moves?

Its covered area sits right at the median of the nine (4.62%) but its average pixel intensity is the highest measured (139.7), because the shadow's alpha ramps from 0 to its cap within 40 pixels of horizontal scroll — a small area changing sharply reads as more, not less, than a wide area changing gently.

Sources: Intersection Observer API · Pointer events · aria-sort · position

Related reading: 9 Swipe to Delete List UI Patterns (Copy-Paste) · Infinite Scroll Loading: 9 Essential UI Patterns · Pull to Refresh CSS: 9 Loading Patterns

Enter the archive password

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