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

Infinite Scroll Loading: 9 Essential UI Patterns

One indicator recomputes opacity from the remaining scroll distance, one offsets scroll position by the height added above, and another renders only the

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

None of the nine reach a real endpoint — every row, batch, and retry lives in a seeded local generator with a setTimeout standing in for network latency, and nothing here calls fetch or opens an XMLHttpRequest. All nine also answer something a visitor actually does: 01, 02, 03, 05, 06, 07, 08, and 09 each answer a real scroll event (plus wheel and touchstart, which hand control back to a visitor the instant they interact and stop the demo's own autoplay preview). Only 04 answers a plain click, since a load-more button has no scroll position to react to in the first place.

Anatomy of nine patterns — when each value recomputes

Nine patterns split into three ways of turning a scroll position, a fetch, or elapsed time into a value worth reacting to, and skipping this layer is what makes an infinite scroll loading animation read as a spinner glued to a scrollbar instead of something that actually measures anything.

Why three patterns recompute their value straight from scroll geometry on every scroll event

01, 03, and 08 each read the scroll position on every single scroll tick rather than waiting for anything else to happen. 01's recomputeFade reads the sentinel's and the view's getBoundingClientRect() on every tick, turns the gap between them into a 0–1 progress value against THRESHOLD_PX, and writes that value straight to the spinner's opacity. 03 also runs on every tick, but it recomputes the comparison value itself first — a threshold distance built from scroll speed — before checking whether the sentinel has crossed it. 08's renderWindow reads scrollTop on every tick and turns it into a start/end index pair instead of a single percentage, since a virtualized list has no single "how far along" number to fall back on.

Why three patterns recompute their value only around a fetch, not on every scroll tick

02, 04, and 07 recompute a value exactly once per request rather than continuously. 02's skeletonCountFor only runs the moment startLoad begins, dividing the remaining viewport height by ROW_H to decide how many placeholder rows to show. 04's morphTo only runs when a load-more click starts and again once new items land, recomputing the button's target width from the label's real rendered width via getBoundingClientRect() rather than a fixed size per state. 07 measures scrollHeight before and after inserting older messages and adds the difference to scrollTop only once that batch's setTimeout resolves — recomputing mid-scroll would correct a height that hasn't changed yet.

Why three patterns recompute their value from elapsed time or a completion count rather than scroll position

05, 06, and 09 answer a real scroll event too, but the value each one recomputes has nothing to do with distance. 05's tryRequest recomputes the gap between performance.now() and the last request's timestamp and compares it to COOLDOWN_MS, regardless of where the sentinel sits. 06 recomputes nothing at all once its remaining-item value hits zero — that's a completion count locking the calculation off, not a scroll position triggering it. 09's runCountdown recomputes a countdown value on a requestAnimationFrame loop after a request fails, driven purely by elapsed time until the next automatic retry fires.

Code per pattern

01Bottom spinner fade — a loading indicator computing its opacity from the remaining scroll distance

Once the list nears its end, the remaining scroll distance is used to recompute an opacity value that gradually deepens the loading indicator, and once new items arrive that value falls back to zero.

Bottom spinner fade — a loading indicator computing its opacity from the remaining scroll distance
Open in a new tab

01 · Bottom spinner fade — a loading indicator computing its opacity from the remaining scroll distance — Measured area 33.365% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

THRESHOLD_PXrecomputeFadegetBoundingClientRect

What the numbers say

Across 23 tracked frames, 01 moves on all 23 of them, covering 33.37% cumulative area at a 33.9 average intensity, because the spinner's opacity is recomputed and repainted on essentially every scroll tick rather than only at the moment loading starts. THRESHOLD_PX sets how many pixels of runway the fade gets before recomputeFade reaches full opacity, and getBoundingClientRect is what turns the distance into real screen pixels rather than a raw scrollTop number that would drift if padding changed.

  function recomputeFade() {
    var viewBottom = view.getBoundingClientRect().bottom;
    var sentinelTop = sentinel.getBoundingClientRect().top;
    var remaining = sentinelTop - viewBottom;
    var progress = 1 - Math.min(Math.max(remaining / THRESHOLD_PX, 0), 1);
    ring.style.opacity = String(progress);
    if (remaining <= 0 && !loading && rowCount < MAX_ROWS) {
      startLoad();
    }
    hint.textContent = (remaining <= 0 && rowCount >= MAX_ROWS)
      ? 'Scroll further to return to the start'
      : 'Scroll to the bottom to load the next batch';
  }

02Skeleton row append — a list computing its preview row count from the viewport height

Once the next page starts loading, the remaining viewport height is divided by a single row's height to compute how many preview rows to show, and once real data arrives that same count of placeholders switches to content.

Skeleton row append — a list computing its preview row count from the viewport height
Open in a new tab

02 · Skeleton row append — a list computing its preview row count from the viewport height — Measured area 34.218% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

skeletonCountFor--staggerROW_H

What the numbers say

Across 23 tracked frames, 02 moves on 21 of them, covering 34.22% cumulative area at a 42.1 average intensity — the highest cumulative area of the nine, since a whole batch of skeleton rows paints in at once rather than one row at a time. ROW_H is the divisor skeletonCountFor uses to turn leftover pixels into a row count, and each skeleton row also gets its own --stagger custom property so its shimmer's animation-delay offsets by row instead of every row flashing in sync.

  // Computed layer: divide the viewport height still left above the sentinel by a single
  // row's height to decide how many skeleton rows to show — bigger screens and more
  // scroll headroom both mean more placeholders blink in before real content lands.
  function skeletonCountFor() {
    var remainingViewport = view.getBoundingClientRect().bottom - sentinel.getBoundingClientRect().top;
    var visible = Math.max(remainingViewport, view.clientHeight);
    return Math.max(2, Math.ceil(visible / ROW_H));
  }

  function startLoad() {
    if (loading || batches >= MAX_BATCHES) { return; }
    loading = true;
    track.setAttribute('aria-busy', 'true');
    var count = skeletonCountFor();

03Load trigger threshold — a list recomputing how early it starts loading from the scroll speed value

Once the scroll speed increases, the value for how far ahead of the edge to start loading is recomputed and the next page is requested that much earlier, and once the speed slows that value shrinks back.

Load trigger threshold — a list recomputing how early it starts loading from the scroll speed value
Open in a new tab

03 · Load trigger threshold — a list recomputing how early it starts loading from the scroll speed value — Measured area 33.015% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

recomputeThresholdSPEED_GAINperformance.now

What the numbers say

Across 23 tracked frames, 03 moves on all 23 of them, covering 33.02% cumulative area at a 48.8 average intensity, since the badge showing the live threshold value repaints on every tick alongside the sentinel dot. recomputeThreshold turns a measured performance.now() delta into a px/ms speed and multiplies it by SPEED_GAIN before capping the result, which is what lets a fast flick through the list start loading noticeably earlier than a slow, deliberate scroll.

  // Computed layer: divide the distance moved since the last scroll event by the elapsed
  // time to get a speed, then scale the trigger threshold by that speed — scrolling faster
  // means the next page is requested earlier, before the sentinel reaches the edge.
  function recomputeThreshold() {
    var now = performance.now();
    var dt = Math.max(now - lastTime, 1);
    var speed = Math.abs(view.scrollTop - lastTop) / dt;
    lastTop = view.scrollTop;
    lastTime = now;
    threshold = Math.min(BASE_THRESHOLD_PX + speed * SPEED_GAIN, MAX_THRESHOLD_PX);
    badge.textContent = 'Threshold ' + Math.round(threshold) + 'px';
  }

04Load-more button morph — a button recomputing its width from the remaining item count

Once a load-more request starts, the button's width value is recomputed to fit a waiting display, and once the next items arrive it returns to its original width or a completed display depending on the remaining-count value.

Load-more button morph — a button recomputing its width from the remaining item count
Open in a new tab

04 · Load-more button morph — a button recomputing its width from the remaining item count — Measured area 2.625% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

morphToWIDTH_LERPrequestAnimationFrame

What the numbers say

Across 23 tracked frames, 04 moves on 10 of them, covering only 2.63% cumulative area — the lowest of the nine, exactly because a single button morphing is a small repaint compared to a full row list — at a 70.7 average intensity, the second-highest of the set, since the pixels that do change are concentrated tightly inside the button's own bounds. morphTo reads the label's real rendered width with getBoundingClientRect() before setting a target, and WIDTH_LERP is the per-frame pull factor a requestAnimationFrame loop uses to ease the button's current width toward that target instead of snapping to it.

  function morphTo(text) {
    label.textContent = text;
    var natural = label.getBoundingClientRect().width;
    targetWidth = Math.round(natural + PADDING_X);
    if (widthRaf) { return; }
    var current = btn.getBoundingClientRect().width;
    function tick() {
      current += (targetWidth - current) * WIDTH_LERP;
      btn.style.width = current + 'px';
      if (Math.abs(targetWidth - current) > 0.5) {
        widthRaf = requestAnimationFrame(tick);
      } else {
        btn.style.width = targetWidth + 'px';
        widthRaf = null;
      }
    }
    widthRaf = requestAnimationFrame(tick);
  }

05Duplicate-call guard — a list blocking its next request from a last-request timestamp value

Once the list's end is reached multiple times within a short span, the gap from the last request's timestamp value is computed, no new request goes out while that value stays under the threshold, and only once it crosses the threshold does the next request fire.

Duplicate-call guard — a list blocking its next request from a last-request timestamp value
Open in a new tab

05 · Duplicate-call guard — a list blocking its next request from a last-request timestamp value — Measured area 27.358% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

tryRequestCOOLDOWN_MSperformance.now

What the numbers say

Across 23 tracked frames, 05 moves on all 23 of them, covering 27.36% cumulative area at a 46.1 average intensity, since a blocked call still flashes the sentinel and increments a visible counter even though no fetch actually starts. tryRequest compares a performance.now() gap to COOLDOWN_MS before deciding whether to proceed, and every rejected attempt bumps the "Blocked calls" counter rather than silently doing nothing, so a visitor scrolling in short, repeated bursts can see exactly how many of their triggers got swallowed.

  function tryRequest() {
    var now = performance.now();
    var since = now - lastRequestAt;
    if (since < COOLDOWN_MS) {
      blockedCount += 1;
      blockedEl.textContent = blockedCount + ' times';
      sentinel.classList.add('dgd__sentinel--blocked');
      setTimeout(function () { sentinel.classList.remove('dgd__sentinel--blocked'); }, 180);
      return false;
    }
    lastRequestAt = now;
    loading = true;
    sentinel.classList.add('dgd__sentinel--loading');
    setTimeout(function () {
      for (var n = 0; n < 3; n += 1) { addRow(); }
      loading = false;
      batches += 1;
      sentinel.classList.remove('dgd__sentinel--loading');
    }, LOAD_DELAY_MS);
    return true;
  }

06End-of-list reveal — a message appearing once the remaining-item value reaches zero

Once fetching the next page brings the remaining-item value to zero, the computation confirms there is nothing left to fetch, and reaching the edge again in that state no longer recomputes the value.

End-of-list reveal — a message appearing once the remaining-item value reaches zero
Open in a new tab

06 · End-of-list reveal — a message appearing once the remaining-item value reaches zero — Measured area 33.577% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

revealEndREVEAL_PXrequestAnimationFrame

What the numbers say

Across 23 tracked frames, 06 moves on 21 of them, covering 33.58% cumulative area at a 39.6 average intensity, most of it spent on the final page's rows appending before the completion message takes over. revealEnd doesn't just toggle a CSS transition — it drives its own requestAnimationFrame loop that eases a REVEAL_PX offset down to zero, and once that loop finishes the exhausted flag it sets means recompute returns immediately on every later scroll tick instead of measuring anything.

  // Computed layer: ease the reveal offset toward zero on a rAF loop rather than a fixed
  // transition — starting from REVEAL_PX and decaying exponentially each frame. This
  // computation itself never runs again once the remaining-item value reaches zero.
  function revealEnd() {
    endBox.hidden = false;
    var y = REVEAL_PX;
    function tick() {
      y += (0 - y) * 0.18;
      endBox.style.transform = 'translateY(' + y.toFixed(2) + 'px)';
      if (y > 0.4) { revealRaf = requestAnimationFrame(tick); }
      else { endBox.style.transform = 'translateY(0px)'; revealRaf = null; }
    }
    revealRaf = requestAnimationFrame(tick);
  }

07Scroll anchor preserve — a list correcting its position by the height value added above

Once older items are loaded above the current view, the newly added height value is computed and added to the scroll position value, keeping the view exactly in place instead of jumping.

Scroll anchor preserve — a list correcting its position by the height value added above
Open in a new tab

07 · Scroll anchor preserve — a list correcting its position by the height value added above — Measured area 23.703% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

loadOlderOLDER_BATCHscrollHeight

What the numbers say

Across 23 tracked frames, 07 moves on 21 of them, covering 23.70% cumulative area at an 86.3 average intensity — the highest average intensity of the nine, since the whole visible chat log shifts by the corrected offset in one frame rather than fading or sliding gradually. loadOlder reads scrollHeight before inserting OLDER_BATCH new bubbles above the anchor and again right after, and the difference between those two scrollHeight reads is what gets added straight back into scrollTop so the row a visitor was reading never moves under their eyes.

  function loadOlder() {
    if (loading || batches >= MAX_BATCHES) { return; }
    loading = true;
    var before = view.scrollHeight;
    anchor.classList.add('asp__anchor--loading');
    setTimeout(function () {
      var frag = document.createDocumentFragment();
      for (var n = 0; n < OLDER_BATCH; n += 1) {
        seq += 1;
        frag.appendChild(bubble(seq, seq % 3 === 0));
      }
      track.insertBefore(frag, anchor);
      var added = view.scrollHeight - before;
      view.scrollTop += added;   // offset scroll position by the height value added above
      valueEl.textContent = 'Offset ' + added + 'px';
      batches += 1;
      loading = false;
      anchor.classList.remove('asp__anchor--loading');
    }, LOAD_DELAY_MS);
  }

08Virtual window recalc — a list rendering only the index range value currently visible

As the list keeps growing, the scroll position value is used to recompute the currently visible index range, items outside that range are not rendered, and once the range shifts the top and bottom spacer values are recomputed to match.

Virtual window recalc — a list rendering only the index range value currently visible
Open in a new tab

08 · Virtual window recalc — a list rendering only the index range value currently visible — Measured area 33.788% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

renderWindowOVERSCANscrollTop

What the numbers say

Across 23 tracked frames, 08 moves on all 23 of them, covering 33.79% cumulative area at a 43.9 average intensity, since every scroll tick can swap the entire visible row set rather than append to it. renderWindow divides scrollTop by ROW_H to get a starting index, pads it by OVERSCAN rows on each side so fast scrolling doesn't reveal a blank edge for a frame, and resizes the top and bottom spacers so the scrollable height stays correct even though only a dozen or so of the available rows ever exist in the DOM at once.

  // Computed layer: turn scrollTop into the currently visible index range (start..end) and
  // recompute it on every scroll tick — rows outside that range don't exist in the DOM.
  // Top and bottom spacer heights are recomputed from the same range to keep total height.
  function renderWindow() {
    var scrollTop = view.scrollTop;
    var first = Math.floor(scrollTop / ROW_H) - OVERSCAN;
    var visible = Math.ceil(view.clientHeight / ROW_H) + OVERSCAN * 2;
    var start = Math.max(0, Math.min(first, available - visible));
    var end = Math.min(available, start + visible);
    if (rendered === start + ':' + end) { return; }
    rendered = start + ':' + end;
    while (win.firstChild) { win.removeChild(win.firstChild); }
    for (var i = start; i < end; i += 1) {
      var li = document.createElement('li');
      li.className = 'vwr__row';
      li.textContent = 'Row ' + String(i + 1).padStart(3, '0');
      win.appendChild(li);
    }
    topSpacer.style.height = (start * ROW_H) + 'px';
    bottomSpacer.style.height = Math.max(0, (available - end) * ROW_H) + 'px';
    rangeEl.textContent = 'Rows ' + start + ' - ' + (end - 1) + ' / ' + available;
    domEl.textContent = 'Rendered rows ' + (end - start);
  }

09Retry countdown — a list re-attempting once its wait-time value counts down

Once the next page request fails, a wait-time value counts down from a fixed number each moment, and once that value reaches zero the same request automatically fires again.

Retry countdown — a list re-attempting once its wait-time value counts down
Open in a new tab

09 · Retry countdown — a list re-attempting once its wait-time value counts down — Measured area 31.313% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

runCountdownCIRCperformance.now

What the numbers say

Across 23 tracked frames, 09 moves on all 23 of them, covering 31.31% cumulative area at a 72.1 average intensity, since the countdown ring's stroke and the seconds label both repaint every rendered frame rather than only at the start and end of the wait. runCountdown recomputes an eased progress value from elapsed time divided by WAIT_S on a requestAnimationFrame loop, and CIRC — the circle's circumference — is what turns that eased value into a strokeDashoffset so the ring visibly drains rather than jumping straight from full to empty.

  // Computed layer: the wait-time value is not a static countdown text — it is recomputed
  // every moment on a rAF loop, feeding elapsed time through an ease-out curve to get both
  // the remaining seconds and the ring length (circumference times the remaining ratio).
  function runCountdown(done) {
    counting = true;
    retryBox.hidden = false;
    var t0 = performance.now();
    function tick(now) {
      var elapsed = (now - t0) / 1000;
      var k = Math.min(elapsed / WAIT_S, 1);
      var eased = 1 - Math.pow(1 - k, 2);   // ease-out — fast at first, slows near the end
      var remainS = WAIT_S * (1 - eased);
      secondsEl.textContent = remainS.toFixed(1);
      arc.style.strokeDashoffset = String(CIRC * eased);
      if (k < 1) { countdownRaf = requestAnimationFrame(tick); }
      else { counting = false; retryBox.hidden = true; done(); }
    }
    countdownRaf = requestAnimationFrame(tick);
  }

Where values reshape a pattern — distance value, index range value, wait-time value

Swap out the value a pattern is built on and the interaction it produces shifts with it, even though the markup underneath never changes.

Why the threshold distance value in 03 changes what "early" means as scroll speed changes

03's threshold is not a fixed 40px — BASE_THRESHOLD_PX only sets the floor. Once scroll speed rises, SPEED_GAIN multiplies that speed and the result gets added on top, capped at MAX_THRESHOLD_PX so a very fast flick still can't trigger a load from halfway up the list. Reading the threshold as a distance value that grows with speed, rather than a constant, is what keeps a fast scroller from ever seeing a blank gap at the bottom.

Why the visible index range value in 08 has to be a pair, not a single scroll fraction

08 could not work off a single "how far scrolled" percentage the way 01 does, because a virtualized list needs to know exactly which rows to keep in the DOM, not just how close it is to the end. renderWindow computes start and end together from scrollTop, ROW_H, and OVERSCAN, and treating that pair as one value — recomputed together, compared together — is what keeps the spacer heights and the rendered rows from drifting out of sync with each other.

Why the retry wait-time value in 09 counts down instead of jumping straight from full to zero

09's WAIT_S could just gate a single setTimeout and flip from "waiting" to "ready" with nothing in between, but recomputing the eased progress every frame instead is what lets the ring visibly drain and the seconds label count down — a visitor watching a failed request can see it is actively retrying soon rather than wondering whether the page has simply stalled.

Where it breaks — fast scrolling, slow networks, an empty list

Where this set differs from 9 CSS Skeleton Loading Screens, Copy-Paste Ready, 9 CSS Scroll Progress Bar Ideas, Copy-Paste, and 9 Swipe to Delete List UI Patterns is the value each one recomputes. That skeleton set recomputes a shimmer's stagger offset per placeholder row rather than a scroll distance. The scroll-progress set recomputes a single fraction of total page height rather than a start/end index pair. The swipe-to-delete set recomputes a drag distance against a release threshold rather than a value tied to a fetch. This set's nine patterns recompute something different again — an opacity from a distance, a row count from a viewport, a threshold from a speed, a width from a label, a cooldown from a timestamp, a completion count, a scroll offset from an inserted height, an index range from a scrollTop, and a countdown from elapsed time. Every one of those nine computations, unlocked with qp334h28, ships ready to drop into a real list.

What fast scrolling does to the duplicate-call guard and the trigger threshold

A visitor who scrolls in short, repeated bursts can hit the sentinel several times before a single fetch resolves — 05's COOLDOWN_MS is exactly what stops that from becoming several overlapping requests, incrementing a visible "Blocked calls" counter instead of silently dropping the extra triggers. 03 takes the opposite approach to the same problem: instead of blocking a fast scroller's extra triggers, it recomputes a wider threshold so the next page starts loading earlier, before repeated triggers even have a chance to happen.

What a slow network does to the retry countdown and the button morph

09 only exists because a real network sometimes fails or stalls, and its countdown gives a visitor a visible reason to wait rather than a frozen list — this is the one place in the set where the simulated setTimeout latency is deliberately allowed to represent failure, not just delay. 04's button morph has a quieter version of the same problem: if a load-more request is slow, the button's waiting label and width stay put until the response lands, so a visitor never sees the button silently revert without an explanation.

What an empty or exhausted list does to the end-message reveal and the virtual window

06 is built specifically for the moment a list runs out — once page reaches TOTAL_PAGES, no further scroll event ever restarts the computation, unlike every other pattern in this set that keeps listening indefinitely. 08 handles the same edge differently: because available is a known, finite number in this demo, Math.min in renderWindow keeps end from ever exceeding it, so scrolling past the last real row simply stops shifting the window instead of rendering an out-of-range slice.

Three variants and accessibility

Treat the numbers below as a starting point once the placeholder rows, timestamps, and messages have been swapped for real data — the computed value stays the same, only the constant changes.

Variant What changes What it trades off
Bottom spinner fade threshold Item 01's THRESHOLD_PX constant from 96px to 220px At 96px the spinner only starts fading in once the list is nearly at its end, so it's easy to miss; at 220px it starts deepening much earlier, giving a longer warning that loading is coming, at the cost of the spinner sitting on screen longer overall
Load-more button morph lerp Item 04's WIDTH_LERP constant from 0.24 to 0.65 At 0.24 the button's width eases toward its target across several frames, reading as weighted and deliberate; at 0.65 it reaches the target width in one or two frames, reading as instant but with no springiness at all
Retry wait time Item 09's WAIT_S constant from 3 to 8 At 3 seconds the countdown ring barely registers before the retry fires again; at 8 seconds the ring's slow sweep makes the wait unmistakable, but a visitor has to watch a stalled list for noticeably longer

This zip ships the 96px, 0.24, and 3-second values.

Why reduced motion and screen-reader users still land on the same computed values

All nine check prefers-reduced-motion and stop their own autoplay preview loop under it, leaving only the real scroll, wheel, touchstart, and click listeners active — a reduced-motion visitor who scrolls the list or presses load-more still gets the same recomputed opacity, threshold, width, or countdown values, just without the demo playing itself. Live regions are used where a value change is worth announcing rather than everywhere: 01's row list is aria-live="polite" so a screen reader hears each new row once, 02 toggles aria-busy on the list while skeleton placeholders stand in for real content, 06's completion message and 09's status line both use role="status". The sentinel and spacer elements in 03, 05, 07, and 08 are marked aria-hidden, since they exist only to measure a position and carry no content of their own — the moving indicator or label next to each one is what actually gets announced.

FAQ

Should you build infinite scroll or load more for a real product?

It depends on what a visitor needs to do after loading — infinite scroll (01, 02, 03, 05, 06, 07, 08, 09 in this set) suits a feed a visitor mostly skims downward, while a load-more button like 04 suits a list a visitor might want to stop, count, or return to a fixed position in, since a click leaves the scroll position exactly where it was.

Are these nine an infinite scroll loading animation example only, or code you can actually ship?

They're built to be used directly rather than kept as an infinite scroll loading animation example alone — each of the nine is 25 lines or fewer of the core computation shown above, uses no external library, and the data layer is the only part meant to be replaced with a real request.

Which of these nine call a real backend, and which just simulate infinite scroll lazy loading locally?

None of them — every row, message, and retry is generated from a seeded local list with a setTimeout standing in for latency, so nothing here is real infinite scroll lazy loading against an actual endpoint. Swapping the data source for a real fetch call is the only change needed to move any of the nine into production.

Sources: MDN — Element.scrollHeight · MDN — Window.requestAnimationFrame · MDN — ARIA status role · MDN — prefers-reduced-motion

Enter the archive password

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