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
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.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/infinite-list.js";
import "@eunomia/elements/loader.js";
</script>
<eun-infinite-list id="team" label="Team members" has-more>
<div>Ada Lovelace</div>
<div>Alan Turing</div>
<div>Grace Hopper</div>
</eun-infinite-list>
<script type="module">
const list = document.getElementById("team");
list.addEventListener("eunloadmore", async (event) => {
const page = await fetchTeamPage(event.page, event.pageSize); // your own API call
for (const member of page.items) {
const row = document.createElement("div");
row.textContent = member.name;
list.append(row);
}
list.loading = false;
if (!page.hasMore) {
list.hasMore = false;
}
});
</script>
npm install @eunomia/elements
import "@eunomia/elements/infinite-list.js";
import "@eunomia/elements/loader.js";
import { useCallback, useRef, useState } from "react";
function TeamList() {
const [members, setMembers] = useState(initialPage); // however you seed page 1
const [hasMore, setHasMore] = useState(true);
const listRef = useRef(null);
const onLoadMore = useCallback(async (event) => {
const page = await fetchTeamPage(event.page, event.pageSize);
setMembers((current) => [...current, ...page.items]);
setHasMore(page.hasMore);
listRef.current.loading = false;
}, []);
return (
<eun-infinite-list
ref={listRef}
label="Team members"
has-more={hasMore}
oneunloadmore={onLoadMore}
>
{members.map((member) => (
// A stable key matters here for the same reason it always does in
// React — without it, re-renders may recreate DOM nodes instead of
// reusing them, which is exactly what eun-infinite-list's own
// virtualization otherwise avoids doing on your behalf.
<div key={member.id}>{member.name}</div>
))}
</eun-infinite-list>
);
}
Before React 19, React doesn't support setting boolean/event props with the same casing as their HTML attribute out of the box on custom elements, so adjust has-more/oneunloadmore to whatever binding convention your version and setup use (a wrapper component, @lit/react, etc.).
npm install @eunomia/elements
"use client";
import "@eunomia/elements/infinite-list.js";
import "@eunomia/elements/loader.js";
import { useCallback, useState } from "react";
export function TeamList({ initialMembers }) {
const [members, setMembers] = useState(initialMembers);
const [hasMore, setHasMore] = useState(true);
const onLoadMore = useCallback(async (event) => {
const page = await fetchTeamPage(event.page, event.pageSize); // Server Action or Route Handler
setMembers((current) => [...current, ...page.items]);
setHasMore(page.hasMore);
event.target.loading = false;
}, []);
return (
<eun-infinite-list
label="Team members"
has-more={hasMore}
oneunloadmore={onLoadMore}
>
{members.map((member) => (
<div key={member.id}>{member.name}</div>
))}
</eun-infinite-list>
);
}
Render the component only on the client (as above, via "use client"), though initialMembers itself can still come from a Server Component/route, so the first page is server-rendered even though the list logic isn't.
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/infinite-list.js";
import "@eunomia/elements/loader.js";
import { ref } from "vue";
const members = ref(initialMembers);
const hasMore = ref(true);
const list = ref(null);
async function onLoadMore(event) {
const page = await fetchTeamPage(event.page, event.pageSize);
members.value.push(...page.items);
hasMore.value = page.hasMore;
list.value.loading = false;
}
</script>
<template>
<eun-infinite-list
ref="list"
label="Team members"
:has-more="hasMore"
@eunloadmore="onLoadMore"
>
<div v-for="member in members" :key="member.id"></div>
</eun-infinite-list>
</template>
Register eun-infinite-list/eun-loader as custom elements in your Vue config (isCustomElement) if they aren't already, same as any other Eunomia component.
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/infinite-list.js";
import "@eunomia/elements/loader.js";
@Component({
selector: "app-team-list",
template: `
<eun-infinite-list
label="Team members"
[attr.has-more]="hasMore"
(eunloadmore)="onLoadMore($event)"
>
<div *ngFor="let member of members; trackBy: trackById">
</div>
</eun-infinite-list>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class TeamListComponent {
members = this.initialMembers;
hasMore = true;
async onLoadMore(
event: CustomEvent & {
page: number;
pageSize: number;
target: HTMLElement & { loading: boolean };
},
) {
const page = await this.fetchTeamPage(event.page, event.pageSize);
this.members = [...this.members, ...page.items];
this.hasMore = page.hasMore;
event.target.loading = false;
}
trackById(_: number, member: { id: string }) {
return member.id;
}
}
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.
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.
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.
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).
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, akeyprop, ...): 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 oneappend()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.
Build each page's items into a DocumentFragment before a single append()
call, instead of creating and inserting elements one by one: the layout
cost of many individual insertions adds up on a long list.
const fragment = document.createDocumentFragment();
for (const member of page.items) {
const row = document.createElement("div");
row.textContent = member.name;
fragment.append(row);
}
list.append(fragment);
Give every item a stable key (an id from your data, never the array
index): without one, React may recreate DOM nodes instead of reusing them
across re-renders, which works against eun-infinite-list's own
virtualization, since it tracks items by their actual node and a recreated
node looks like a brand-new item to it. Keep the members state array itself
append-only for pagination ([...current, ...page.items]) rather than
replacing it wholesale, for the same reason.
Same as React (this is still React underneath): stable keys per item,
an append-only state update in your eunloadmore handler. Seed the first
page from the server (a Server Component prop, a route handler) so it's
part of the initial HTML, and let eun-infinite-list take over pagination
from there on the client only.
Give every item a stable :key in your v-for (an id, not the loop
index): Vue's own reuse-by-key diffing otherwise risks recreating nodes on
re-render, again working against the component's own node-identity-based
virtualization. Push new pages into the existing array (members.value.push(...))
rather than reassigning it.
Set a trackBy function on your *ngFor (returning each item's id):
without it, Angular's default tracking is by object identity, and a fresh
array reference (even with the same underlying items) can cause it to
recreate every row. Reassign members to a new array that's the old one
plus the new page ([...this.members, ...page.items]), not the other way
around.
Alternatives
| You want… | Prefers |
|---|---|
| Discrete, numbered pages someone navigates explicitly, with a URL that reflects which page they're on | eun-pagination |
Guidance
- Set
labelto a short, specific accessible name ("Search results", "Team members"), see the Accessibility tab for why it matters here specifically - Set
hasMoretofalsethe 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-heightunset 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-columnsfor 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/bufferPagesif 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
hasMoreback tofalseonce an API-paginated dataset actually runs out: the list keeps asking for more, forever, on every scroll near the end - Turning
virtualizeon 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
| Name | Type | Default | Description |
|---|---|---|---|
| page-size | number | 20 | The 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 |
| virtualize | boolean | false | Detaches 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-threshold | number | 100 | The 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-pages | number | 1 | The 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 |
| loading | boolean | false | Whether 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-more | boolean | false | Whether 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-label | string | — | 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 |
| label | string | — | The accessible name for the feed region. Strongly recommended |
| root-margin | string | '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
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| 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
| Name | Description |
|---|---|
| (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 |
| end | Shown 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 |
| empty | Shown while there are zero items and loading is false |
Events
| Name | Type | Description |
|---|---|---|
| eunloadmore | LoadMoreEvent | Fired 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
CSS custom properties
| Name | Description |
|---|---|
| --infinite-list-height | Sets 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-columns | Sets the number of columns items are laid out in, as a grid. Above 1, virtualization is turned off |
| --infinite-list-gap | Sets the gap between items |
| --infinite-list-padding | Sets the padding around the scrollable area |
| --infinite-list-padding-block | Sets the vertical padding, as one length applied to both sides. Ignored when --infinite-list-padding is set |
| --infinite-list-padding-inline | Sets the horizontal padding, as one length applied to both sides. Ignored when --infinite-list-padding is set |
| --infinite-list-loading-padding | Sets the padding around the trailing loading indicator |
| --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 |
CSS shadow parts
| Name | Description |
|---|---|
| feed | The 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.
<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.
<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
createDragReorderSpace, then the arrow keys, then Space again.
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
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 the320pxused in the other examples, so this one scrolls internally sooner--infinite-list-gap:4pxinstead of the16pxdefault, so items pack tightly instead of reading as separated cards--infinite-list-padding:8pxof 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
Custom (compact) spacing
<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"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
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
feed role
article role