Eunomia v1.1.0
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 with virtualization turned on, items far from the current scroll position are quietly removed from view once the list grows large, then 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: add the bare virtualize attribute to keep 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
  • Set --infinite-list-columns for a grid of cards, so each card stays a direct child of the list and keeps its place in the feed
  • Set item-tab-stop="none" when every item already holds its own link or button, so each card is a single Tab stop
  • 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
  • Turning virtualize on for items a framework renders: detaching and reattaching them behind the framework's back can break its own bookkeeping
  • Wrapping each item in an element that already carries role="article" inside: put the role on the direct child instead, 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
virtualizebooleanfalseDetaches pages far from the current scroll position once the list exceeds virtualizeThreshold, replacing each with a single sized spacer. Ignored while --infinite-list-columns is above 1, and best left off when a framework renders the 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—The accessible label passed to the trailing loading indicator. Defaults to the localized string for the current locale (English: "Loading more items…")
item-tab-stop'roving' | 'none''roving'How items enter the tab order. With roving, the active item is the list's single Tab stop. With none, no item is a Tab stop, so Tab only reaches the links and buttons inside them, while the arrow, Page Up/Down, Home and End keys still move between items
labelstring—The 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.

Properties

JS-only — no matching HTML attribute, set these from a script or a template binding.

NameTypeDefaultDescription
resolvedLocale—This instance's own `locale` when set, the shared default locale otherwise. Used for `Intl` formatting, so dates and numbers follow the same locale as dictionary.
dictionary—This instance's locale-resolved string dictionary — see the class doc for the resolution order.

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-columnsSets the number of columns items are laid out in, as a grid. Above 1, virtualization is turned off
--infinite-list-gapSets the gap between items
--infinite-list-paddingSets the padding around the scrollable area
--infinite-list-padding-blockSets the vertical padding, as one length applied to both sides. Ignored when --infinite-list-padding is set
--infinite-list-padding-inlineSets the horizontal padding, as one length applied to both sides. Ignored when --infinite-list-padding is set
--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

CSS shadow parts

NameDescription
feedThe element wrapping the items, laid out as a grid

Data sources

Static, virtualized

100 pre-rendered "wide" cards, all slotted upfront: no has-more needed, false is already the default. The bare virtualize attribute turns virtualization on, and 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"
  virtualize
  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>

Grid of cards

--infinite-list-columns lays the items out as a grid, while each card stays a direct child of the list, so it keeps its own aria-posinset and its place in keyboard navigation. Change the count in a media query to adapt it to the screen width. Since every card here already holds its own link, item-tab-stop="none" makes that link the card's single Tab stop.

<eun-infinite-list label="Catalog" item-tab-stop="none" class="catalog">
  <article><a href="/kettle">Kettle</a></article>
  <article><a href="/teapot">Teapot</a></article>
  <!-- ... -->
</eun-infinite-list>
.catalog {
  --infinite-list-columns: 1;
}

@media (width >= 768px) {
  .catalog {
    --infinite-list-columns: 3;
  }
}

Virtualization is turned off while there's more than one column, since collapsing a page into a single spacer would also collapse the grid's rows. For anything a column count can't express, ::part(feed) targets the element wrapping the items.

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");

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 is on and 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. Keep virtualize off, its default, on a list you reorder, the same restriction as `eun-table`'s own reordering example.

Custom appearance

Every visual knob is a CSS custom property set directly on the element, with ::part(feed) left for anything else, 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>

The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.

Variable Sets
--infinite-list-end-color Sets the color of the default end and empty slot area
--infinite-list-focus-outline-color Sets a fallback focus outline color applied to slotted items that don't set their own

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).

Give that role to the direct child itself. If an item wraps a component that already carries role="article", such as a card inside an animation wrapper, the list adds a second article around it: move the role and the accessible name up to the direct child, or make the card itself the direct child.

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. The other articles carry no tabindex at all rather than a negative one, which on a custom element would also take every link inside it out of the tab order.

When every article already holds its own link or button, set item-tab-stop="none": no article is a Tab stop anymore, so Tab goes from one card's link to the next instead of stopping twice per card. The keys below still move focus between articles, and the list gives the article it focuses a temporary tabindex="0", removed as soon as focus leaves it. A negative value would take the links of a custom element article out of the tab order while it holds focus.

Key Action
Tab / Shift+Tab Moves focus to/from the currently-active article (or its own interactive content), or only through the articles' content with item-tab-stop="none"
↓ / 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

virtualize is off by default. Once turned on, it kicks in past virtualizeThreshold items, and 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 leave virtualize off for that list, trading the DOM-size benefit back for full reachability.

virtualize assumes a single column of 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 grid, where collapsing several items into one spacer would also collapse the grid's own row count. It's therefore ignored while --infinite-list-columns is above 1.

Reference links

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