UIDex

Infinite Scroll

التمرير اللانهائي

IntersectionObserver

also calledendless scroll، continuous scroll، infinite list، auto load on scroll، endless feed، scroll pagination، سكرول لانهائي، انفينيت سكرول، التمرير المتواصل، التحميل عند التمرير، القائمة اللانهائية، الخلاصة المتواصلة

Infinite scroll fetches the next batch as the reader approaches the end of what is already rendered, then appends that batch to the same page. Scroll position is what fires the request, so the reader never decides that more should load. It is not pagination: pagination gives every slice a fixed address such as ?page=7 and swaps the content out, so the page stays the same size, while an infinite list piles rows into one DOM with no ceiling and no address to return to. It is not a load-more button either, since the mechanics are identical (same request, same append) and the only difference is that the button waits for a press, which by itself removes most of the accessibility damage. Nor is it virtualization, which is a rendering technique that keeps only the rows near the viewport in the DOM and works just as well on data that is already fully loaded, and the two get paired because an infinite list that never recycles rows chokes the page after a few batches. Two questions settle it: will anyone need to link to one specific item, and does anything the user needs live below the list? A yes to either means pagination or a button.

If you called it…

"the page that keeps loading more as I scroll""a feed with no end like a social app""I can never reach the footer""the back button drops me at the top again""new rows just show up at the bottom on their own"

Live specimen

Interact with the demo. Every part is real and numbered.

آخر التحديثات
١تحديث سياسة الخصوصية
٢تقرير أداء الأسبوع
٣ملاحظات اجتماع الفريق
٤مراجعة تصميم الصفحة
٥طلبات الدعم الجديدة
٦قائمة المهامّ المتأخّرة
عنصر الاستشعار

المعروض ٦ من ١٢

RTLالتمرير على المحور الكتلي، فترتيب الدفعات لا ينعكس مع اتجاه الصفحة

Anatomy: every part, named

Hover a row to locate it in the demo above.

Build prompt

Build an infinite list on top of cursor pagination, not offset pagination. Append each batch to the existing list, keying every row by its id rather than its array index. Put an empty sentinel after the last row, watch it with an IntersectionObserver using a bottom rootMargin of about 400px, and pass the real scrolling container as root when the list does not scroll the window. Guard the fetch with an isLoading flag and unobserve while it runs so the same page is never requested twice. Add a real load more button with <button type="button"> and keep it visible at all times, since it is the only control a reader can activate on purpose. From first paint, keep a visually hidden aria-live="polite" region in the page and write a line like "20 more items loaded, 60 of 348" into it after every batch, and set aria-busy="true" on the list during the request. Reserve the height of the incoming rows in the loading indicator so the scroll position does not jump. Update ?cursor= in the address with history.replaceState whenever the visible batch changes, set history.scrollRestoration = "manual", and save both the scroll offset and the number of loaded batches in sessionStorage so a back navigation lands where the user left. Show an explicit end message with the total when hasNextPage === false and remove the sentinel at that point, and show a failure message with a retry button instead of retrying in a loop. Keep nothing anyone needs in the footer and move those links into the header or a sidebar. Use logical properties throughout: inset-inline-end for the back-to-top button and the spinner, text-align: start, padding-inline, and never left or right. If the list runs horizontally, detect the end from a sentinel placed last in the DOM rather than from scrollLeft.

How it behaves right-to-left

This section is ours alone.

Which axis actually flips

never mirrors

A vertical feed does not mirror: page direction rules the inline axis, this list scrolls on the block axis, and row order and the direction of growth come out identical in Arabic and in English. The one trap is rootMargin, which is purely physical, written in CSS margin order (top, right, bottom, left), with no logical keyword anywhere in the API. A vertical list cares only about the third value and it is the same either way, while a horizontal list needs the second value in LTR and the fourth in RTL, so compute it from direction instead of hard-coding it.

The spinner and the skeleton rows

never mirrors

A spinner never mirrors: a blanket [dir="rtl"] svg { transform: scaleX(-1) } reverses its apparent rotation and it reads as spinning backwards. Only its position moves, so pin it with inset-inline-end when it hangs in a corner, or drop it in a flex row and let the inline axis place it. Skeleton rows that shimmer with linear-gradient(to right, …) need the override written by hand, because gradients take no logical keywords at all, so the sweep runs against the reading direction in Arabic until you swap in to left under [dir="rtl"].

The item counter

never mirrors

The number itself does not flip: the Unicode bidi algorithm classifies digits as EN and AN and keeps them running left to right inside Arabic text, so "60 of 348" holds its internal order with no work from you. The one decision left is the numeral system, and it has to hold everywhere: new Intl.NumberFormat("ar-EG").format(348) returns Arabic-Indic digits, while adding -u-nu-latn to the tag returns Latin ones. Do not mix the two on one screen, because an Arabic-Indic counter above Latin row numbers reads as two different things being counted.

The horizontal carousel

mirrors

In a horizontal infinite list the end of the row moves to the left in Arabic, and the sentinel moves with it. Do not detect the end with scrollLeft + clientWidth >= scrollWidth: Chrome, Firefox and Safari now agree on one model where scrollLeft is 0 at the logical start and goes negative as the user moves toward the end in RTL, so that condition never becomes true and Math.abs(el.scrollLeft) is the least you need. Better still, skip the arithmetic: make the sentinel the last node in the DOM and let IntersectionObserver decide, since DOM order is logical order in both directions and flex-direction: row already puts the first card at the logical start with no row-reverse anywhere.

The direction of incoming text

never mirrors

Feed content arrives mixed: an Arabic post, then a repository name in English, then a line that starts with a number. A row takes its direction from the row and never from the page, so put dir="auto" on the text element inside each row rather than on the list, and align with text-align: start instead of text-align: right so the alignment follows whatever each row resolved. Flipping the page changes nothing about a Latin row, and that is the point: resolve direction on the container alone and every Latin line hangs its full stop and its brackets at the wrong end.

In code

Each row is one framework's word for the same thing. Take the row your project speaks.

DOMnew IntersectionObserver(cb, { root, rootMargin })The watcher that replaces scrollTop arithmetic; rootMargin is physical, in CSS margin order.
ARIAaria-live="polite" + aria-busy="true"The only signal a screen reader gets when new rows are appended.
HTML<button type="button">An explicit request for the next page; the only other way in is scrolling until the sentinel intersects.
TanStack QueryuseInfiniteQuery({ getNextPageParam })Hands you fetchNextPage, hasNextPage and isFetchingNextPage on top of cursor paging.
CSScontent-visibility: auto + contain-intrinsic-sizeKeeps far-off rows out of rendering; without the intrinsic size the scrollbar jitters.
DOMhistory.scrollRestoration = "manual"Automatic restoration misses because the rows are not loaded yet, so save and restore the offset yourself.

See also

Updated · 2026-08-19