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
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: 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.
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
eun-paginationGuidance
- 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 - 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 - A CSS grid/multi-column layout for items with
virtualizeleft 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
| 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 | true | Detaches 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-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 | 'Loading more items…' | The accessible label passed to the trailing loading indicator |
| 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
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-gap | Sets the gap between items |
| --infinite-list-padding | Sets the padding around the scrollable area |
| --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 |
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.
<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.
<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 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>
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");
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
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).
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
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
feed role
article role