Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Infinite list

Infinite list wraps a list of items and requests more automatically as someone scrolls near the end, the same idea as pagination, just triggered by scroll position instead of a button. A loading indicator shows while the next page is on its way, and once the list grows large, items far from the current scroll position are quietly removed from view and brought back once scrolled near again, without losing their state.

Dependencies

eun-loader · if loading
Overview API Examples Accessibility
Ada Lovelace
Mathematician (London, UK)
Alan Turing
Computer scientist (Maida Vale, UK)
Grace Hopper
Rear Admiral, US Navy (New York, US)

When to use

Reach for an infinite list whenever a list is too long to load or render all at once, and scrolling for more fits the content better than numbered pages, such as a feed, a search results panel, a chat history, or a media grid.

It's deliberately unopinionated about what an item actually is: each direct child counts as one, whatever it's built from. If you instead need discrete, numbered pages someone navigates explicitly, with a URL that reflects which page they're on, see Alternatives below for a better fit.

Install & usage

Pick a framework in the toolbar above and these snippets adapt.

npm install @eunomia/elements
import "@eunomia/elements/infinite-list.js";
import "@eunomia/elements/loader.js";
<eun-infinite-list label="Team members">
  <div>Ada Lovelace</div>
  <div>Alan Turing</div>
  <div>Grace Hopper</div>
</eun-infinite-list>

Importing the file registers <eun-infinite-list> as a custom element, and eun-loader must be imported alongside it too, for the same reason any component that only renders another one internally needs it (see eun-search's own "Loading" section). It works with any framework, or none, since it's a standard web component. No has-more attribute is needed above, since false (nothing more to fetch) is the default. See "Static vs. API-paginated" further down for the two real usage patterns, including the one that does need it.

Static vs. API-paginated

Both patterns slot items the exact same way: the only difference is how many you hand over upfront, and what you do in response to eunloadmore.

Slot the full set at once (e.g. a local array rendered in full) and leave has-more unset: false is its default, so no attribute is needed at all for a purely static list. Nothing gets fetched: virtualization alone keeps this cheap once the list is long, by keeping only a small window of items mounted at any time regardless of how many exist in total.
Slot only the first page, and set the bare has-more attribute (or hasMore = true) to opt in. On eunloadmore, fetch the next page and append its items as plain children (list.append(...newItems) or your framework's own list rendering), then set loading = false, and hasMore = false once the server reports there's nothing left.

has-more is a plain HTML boolean attribute, so it can only be present (true) or absent (false), never set to the literal string "false" to mean false, the same as disabled or checked anywhere else in HTML. That's why false is the default here rather than true: it's the only one of the two that a static page can express with zero JavaScript at all.

Virtualization keeps the rendered DOM small, but every slotted item still has to exist as a real element at least once before it can be virtualized: for a truly large in-memory dataset (tens of thousands of rows), constructing that many DOM nodes upfront is itself the expensive part. Past a few thousand items, paginate against your own array through the exact same eunloadmore mechanism (slice it yourself and append only that slice) instead of rendering all of it at once.

Sorting & filtering

Whenever sort or filter criteria change, apply them to your whole dataset: every item, whether it's currently mounted, virtualized-out, or not yet fetched at all, then replace the list's content with the new result, starting from page one again. Never re-sort/re-filter only what's currently slotted, since that's an arbitrary subset (whatever's been scrolled through and fetched so far), and silently dropping virtualized-out or not-yet-loaded items from the result would be wrong regardless of how the list happens to be displaying itself at that moment.

You don't need to call anything to signal this: replacing the list's children (list.replaceChildren(...newSortedItems), or your framework's own re-render) is detected automatically: a full replacement scrolls back to the top and renumbers items from 1, while appending strictly more items past what was already there (the infinite-scroll growth case) preserves scroll position instead. Set hasMore to whatever's correct for the new result set either way (e.g. back to true if the new filtered query has its own further pages).

Sorting or filtering by reordering/removing only the elements currently slotted in the list. It looks correct with a short, fully-loaded list and silently produces a wrong result once the list is long enough that some of it hasn't loaded or is currently virtualized-out.

Performance, per framework

The core techniques (scroll-triggered fetch, DOM virtualization once the list is long) are framework-agnostic (see the Accessibility tab for exactly how virtualization works). A few extra, framework-specific habits matter too, and are easy to get backwards:

  • Keep whatever identifies an item (an id, a stable key) attached to its element (dataset.id, a key prop, ...): needed by most of the framework-specific advice below, and generally good practice regardless.
  • Batch DOM insertions when appending a fetched page: build the new items into a DocumentFragment (or an array, in a framework) and append/render them all at once, rather than one append() call per item.
  • Don't rebuild the entire list on every fetched page: append the new page's items to whatever's already there instead of re-rendering everything from scratch, in vanilla JS exactly as much as in a framework.

Alternatives

You want .. Prefers Discrete, numbered pages someone navigates explicitly, with a URL that reflects which page they're on eun-pagination

Guidance

  • Set label to a short, specific accessible name ("Search results", "Team members"), see the Accessibility tab for why it matters here specifically
  • Set hasMore to false the moment you know there's nothing left, in both patterns, since otherwise the list keeps requesting/showing a loader for pages that don't exist
  • Leave --infinite-list-height unset for a list embedded in a normal page flow (the page itself scrolls it), and set it for a bounded panel instead: a sidebar, a modal, a fixed-height card
  • Raise virtualizeThreshold/bufferPages if item content genuinely needs to stay in the DOM further from the viewport than the defaults keep it (e.g. for in-page "Find" to reach it), see the Accessibility tab's virtualization note
  • Forgetting to set hasMore back to false once an API-paginated dataset actually runs out: the list keeps asking for more, forever, on every scroll near the end
  • A CSS grid/multi-column layout for items with virtualize left on: it assumes a single-column, block/flex-column flow, see the Accessibility tab
  • Re-sorting/filtering only the currently-slotted items instead of the whole dataset, see "Sorting & filtering" above

Live testing

Properties

Infinite list <eun-infinite-list>

Attributes

NameTypeDefaultDescription
page-sizenumber20The number of items per page, both the unit the load-more event requests are made in, and the chunk size virtualization mounts and unmounts as a whole
virtualizebooleantrueDetaches pages far from the current scroll position once the list exceeds virtualizeThreshold, replacing each with a single sized spacer. Sound for a single-column, block or flex-column layout, not for a CSS grid of items
virtualize-thresholdnumber100The total known item count above which virtualize actually starts detaching pages. Below it, every page stays mounted, since the bookkeeping isn't worth it for a short list
buffer-pagesnumber1The number of extra pages kept mounted before and after the page currently in view, so a small scroll doesn't immediately re-trigger a mount or unmount
loadingbooleanfalseWhether a load-more request is in flight. The list sets this to true itself right before dispatching the event, since only it can detect the scroll trigger. Setting it back to false once the new items are appended, or there aren't any, is your own responsibility, exactly like the search and select components' own loading
has-morebooleanfalseWhether more items can still be obtained, from local unrevealed children or a future load-more page. Defaults to false, so the zero-config, no script needed static case is the default, and API-paginated consumers opt in explicitly. Set it back to false once you've exhausted your dataset, or it keeps requesting empty pages forever
loading-labelstring'Loading more items…'The accessible label passed to the trailing loading indicator
labelstringThe accessible name for the feed region. Strongly recommended
root-marginstring'400px'How far before the physical end of the list the load-more event triggers, as a CSS-like margin. The default preloads a page before the user actually hits the bottom, so the loading indicator rarely shows for long

Import the exact TypeScript type behind any property above, see Types.

Slots

NameDescription
(default)The items themselves. Each direct child is treated as one item, in document order, regardless of its tag name. Combine with pagination by slotting only the first page, or hand over the full local set upfront
endShown once hasMore is false, its own default, and every item has been slotted at least once. Empty by default, rendering nothing until you provide content
emptyShown while there are zero items and loading is false

Events

NameTypeDescription
eunloadmoreLoadMoreEventFired when more items are needed and hasMore is true. Loading is already true by the time this fires

Every event above follows the same naming convention, covered in Events.

CSS custom properties

NameDescription
--infinite-list-heightSets a fixed height, past which the list scrolls internally instead of the surrounding page. Unset, the default, the list grows with its content and the page scrolls it like any other block
--infinite-list-gapSets the gap between items
--infinite-list-paddingSets the padding around the scrollable area
--infinite-list-loading-paddingSets the padding around the trailing loading indicator
--infinite-list-end-colorSets the color of the default end and empty slot area
--infinite-list-focus-outline-colorSets a fallback focus outline color applied to slotted items that don't set their own

Static, virtualized

100 pre-rendered "wide" cards, all slotted upfront: no has-more needed, false is already the default. virtualizeThreshold is lowered to 20 here purely so the effect is visible within a small demo, since the default (100) is fine for real use and is only lowered here to keep the demo itself small.

The counter above the list is not part of the component: it's this demo's own MutationObserver, wired below, counting how many of the 100 cards are actually real DOM elements right now. Scroll and watch it move: it never grows much past the ~60 needed to cover the visible area plus buffer, no matter how far you scroll into the 100.

Real DOM elements right now, out of 100:
<eun-infinite-list label="Team members" style="--infinite-list-height: 320px;">
  <!-- all 100 cards, rendered upfront -->
</eun-infinite-list>

API-paginated

Only the first page is slotted, and the bare has-more attribute opts into fetching more (false, the default, would never trigger eunloadmore at all). Scrolling to the bottom shows the loader and fires eunloadmore, and this demo answers it with a simulated ~700ms "request" that appends 10 more items, five times, then sets hasMore to false. page-size="10" keeps every page (the pre-seeded one included) the same size, so event.pageSize matches what was actually seeded.

<eun-infinite-list
  id="results"
  label="Search results"
  has-more
></eun-infinite-list>
const list = document.getElementById("results");

list.addEventListener("eunloadmore", async (event) => {
  const response = await fetch(
    `/api/results?page=${event.page}&pageSize=${event.pageSize}`,
  );
  const { items, hasMore } = await response.json();
  list.append(...items.map(renderResult)); // your own item rendering
  list.loading = false;
  list.hasMore = hasMore;
});

Empty and end states

The empty slot shows while there are zero items and loading is false, while the end slot shows once hasMore is false and at least one item has been shown. Both are empty by default: render nothing until you provide content.

No results yet.
Only item
You've reached the end.
<eun-infinite-list label="Search results">
  <div slot="empty">No results yet.</div>
  <div slot="end">You've reached the end.</div>
  <!-- items -->
</eun-infinite-list>

Custom appearance

Every visual knob is a CSS custom property set directly on the element (no ::part/shadow-piercing selector needed), and the full list is in the API tab. This example tightens the three spacing-related ones for a dense list, and compares it against the default spacing (--infinite-list-gap: 16px, no padding of its own) used everywhere else on this page:

  • --infinite-list-height: a shorter fixed height than the 320px used in the other examples, so this one scrolls internally sooner
  • --infinite-list-gap: 4px instead of the 16px default, so items pack tightly instead of reading as separated cards
  • --infinite-list-padding: 8px of breathing room around the scrollable area itself, inside the rounded corners below

The rows below have their own border only so the tighter gap is visible in this screenshot-sized demo, though that border is regular content styling, not something this component controls.

Default spacing

Row 1
Row 2
Row 3
Row 4
Row 5
Row 6

Custom (compact) spacing

Row 1
Row 2
Row 3
Row 4
Row 5
Row 6
<eun-infinite-list
  label="Compact list"
  style="--infinite-list-height: 200px; --infinite-list-gap: 4px; --infinite-list-padding: 8px;"
>
  <div>Row 1</div>
  <div>Row 2</div>
  <!-- ... -->
</eun-infinite-list>

Reordering items

eun-infinite-list has no reordering of its own, but its items are plain slotted children: direct children of the same element, exactly what createDragReorder (the framework-agnostic drag-and-drop utility) needs. Grab a row by its handle (the grip icon) and drag it up or down, or focus the handle and press Space, then the arrow keys, then Space again.

Atomic Habits
Deep Work
The Pragmatic Programmer
Thinking in Systems
Designing Data-Intensive Applications

Reading order:

<eun-infinite-list label="Reading list">
  <div class="row">
    <button class="drag-handle" aria-label="Reorder Atomic Habits">
      <eun-icon name="drag_indicator"></eun-icon>
    </button>
    <span>Atomic Habits</span>
  </div>
  <!-- ...more rows... -->
</eun-infinite-list>
import { createDragReorder } from "@eunomia/elements";

const list = document.querySelector("eun-infinite-list");
list.virtualize = false;

createDragReorder(list, {
  items: () => Array.from(list.children),
  handle: ".drag-handle",
  onReorder: ({ order }) => {
    readingList = order.map((row) => row.dataset.id);
  },
});
createDragReorder measures real, currently-mounted neighbors via getBoundingClientRect(), and once virtualize kicks in (past virtualizeThreshold items, 100 by default), items far from the current scroll position are detached from the DOM entirely, which it has no way to account for. The demo above sets list.virtualize = false explicitly for that reason, so reach for reordering on a bounded, non-virtualized list only, the same restriction as `eun-table`'s own reordering example.

The Feed pattern

An infinite, auto-loading list has no native HTML equivalent, so the wrapper carries role="feed", the ARIA pattern purpose-built for exactly this: indefinitely-long content that loads more of itself as the user reaches its end. Each item automatically gets role="article" (unless it already sets its own role, its choice is always respected), plus aria-posinset/aria-setsize reflecting its position and the total count, or -1 for the total while hasMore is true (the real total genuinely isn't known yet).

aria-busy="true" is set on the feed itself while loading is set, and a visually-hidden, polite live region announces how many new items were just added right after they're appended, independently of loading, so it still fires even if a consumer forgets to flip it back to false promptly.

Keyboard interactions

Each article is reachable individually via a roving tabindex: only one (initially the first) is a Tab stop at any time, and reaching it and pressing Tab again moves on to whatever's next in the page, or into the article's own interactive content, exactly like Tab behaves anywhere else.

Key Action
Tab / Shift+Tab Moves focus to/from the currently-active article (or its own interactive content)
/ Page Down Moves focus to the next article, mounting its page first if it's currently virtualized-out
/ Page Up Moves focus to the previous article, same mounting-on-demand as above
Home Jumps to the first article, same mounting-on-demand as above
End Jumps to the last known article, same mounting-on-demand as above
Ctrl+End / ⌘+End Skips past the entire list, to the first focusable element after it
Ctrl+Home / ⌘+Home Skips back before the entire list, to the first focusable element before it

The arrow keys are the primary, discoverable way to move between items, while Page Down/Page Up do the exact same thing and are kept working alongside them since they're the literal keys the APG Feed pattern specifies. Plain Home/End (jump to the first/last known item) aren't part of that pattern itself, but match the behavior most people already expect from Home/End in any other list-like widget, while Ctrl+Home/ Ctrl+End remain the way to leave the list entirely.

Ctrl+Home/Ctrl+End exist specifically so a keyboard user never has to press Page Down hundreds of times to get past a very long feed: they jump straight out of it in either direction.

Virtualization and assistive technology

Once virtualize kicks in (past virtualizeThreshold items), pages far from the current scroll position are genuinely removed from the DOM, not merely hidden: the same nodes are cached and reattached as-is (state, listeners and all) once scrolled back near, rather than recreated. This keeps the live accessibility tree small on a very long feed, which is generally a good thing, but it does mean an in-page "Find" (Ctrl+F) or a screen reader's own text search can't reach content that's currently virtualized-out, since it isn't in the DOM at all at that moment.

If some content genuinely needs to stay reachable that way regardless of scroll position, raise bufferPages (keeps more mounted around the visible area) or set virtualize to false entirely for that list, trading the DOM-size benefit back for full reachability.

virtualize assumes a single-column, block/flex-column layout for items: a virtualized-out page collapses down to a single sized spacer, which preserves total scroll height correctly for a simple vertical stack but not for a CSS grid/multi-column arrangement, where collapsing several items into one spacer would also collapse the grid's own row/column count. Set virtualize to false for a grid-based layout.

Reference links

WAI-ARIA APG: Feed Pattern
WAI-ARIA 1.2: feed role
WAI-ARIA 1.2: article role