Headless infinite scroll: sentinel-driven paging, load-more fallback, live status, and APG feed.
|
|
| Package |
@vizejs/ui/infinite-scroll |
| Maturity |
stable |
| Own the source |
vize lib pull infinite-scroll |
| Requires |
context, id, measure |
| Aliases |
infinite scroll, load more, feed, endless scroll, pagination sentinel |
| Covers |
WAI-ARIA Feed, IntersectionObserver, VueUse useInfiniteScroll, React Aria useLoadMore |
Minimal setup
Use a Vue 3.5+ project with Vue SFC compilation configured (see Vite+ integration).
vp install @vizejs/ui
Add the public import below to <script setup lang="ts">. Components provide behavior without styles; the preview uses optional Paper styles.
import { InfiniteScrollItem, InfiniteScrollLoadMore, InfiniteScroll, InfiniteScrollRoot, InfiniteScrollSentinel, InfiniteScrollStatus } from "@vizejs/ui/infinite-scroll";
Or copy the source into your project with vize lib pull infinite-scroll (see Source Distribution).
Try it
This is the basic example maintained with the component source, using the public package imports. The preview adds the optional Paper styles.
Open the interactive example · Styling these components
Copy the example
<!-- Feed that pages in more posts from a sentinel or a load-more button, with a live status. -->
<script setup lang="ts">
import { computed, ref } from "vue";
import {
InfiniteScrollItem,
InfiniteScrollLoadMore,
InfiniteScrollRoot,
InfiniteScrollSentinel,
InfiniteScrollStatus,
} from "@vizejs/ui/infinite-scroll";
const total = 12;
const pageSize = 4;
const posts = ref(Array.from({ length: pageSize }, (_, index) => `Release note #${index + 1}`));
const hasMore = computed(() => posts.value.length < total);
async function loadPage(): Promise<void> {
const start = posts.value.length;
const next = Array.from({ length: pageSize }, (_, index) => `Release note #${start + index + 1}`);
posts.value = [...posts.value, ...next];
}
</script>
<template>
<InfiniteScrollRoot aria-label="Release notes" feed :total :has-more :loader="loadPage">
<InfiniteScrollItem v-for="(post, index) in posts" :key="post" :index>
{{ post }}
</InfiniteScrollItem>
<InfiniteScrollSentinel />
<InfiniteScrollLoadMore>Load more notes</InfiniteScrollLoadMore>
<InfiniteScrollStatus v-slot="{ state }">
{{ state === "loading" ? "Loading notes…" : state === "complete" ? "All notes loaded" : "" }}
</InfiniteScrollStatus>
</InfiniteScrollRoot>
</template>
API
Source: src/families/data/infinite-scroll/infinite-scroll-item.vue
Props
| Prop |
Type |
Default |
Description |
index (required) |
number |
required |
Zero-based item position, announced one-based through aria-posinset. |
ariaLabelledby |
string |
undefined |
Ids of the article title, per the feed pattern. |
ariaDescribedby |
string |
undefined |
Ids of the article's primary content. |
Slots
| Slot |
Slot props |
Description |
default |
InfiniteScrollItemSlotState |
Article content. Receives the announced position and set size. |
Exposed
| Member |
Type |
Description |
position |
number |
One-based position announced through aria-posinset. |
setSize |
number |
Announced set size; -1 while the total is unknown. |
element |
HTMLElement | null |
Rendered article element. |
Source: src/families/data/infinite-scroll/infinite-scroll-load-more.vue
Props
| Prop |
Type |
Default |
Description |
hideWhenComplete |
boolean |
true |
Apply the native hidden attribute once every item is loaded. |
Events
| Event |
Payload |
Description |
click |
[nativeEvent: MouseEvent] |
Fired before the request. Call preventDefault() to keep state unchanged. |
Slots
| Slot |
Slot props |
Description |
default |
InfiniteScrollSlotState |
Button label. Receives the loading state, e.g. to render "Retry" after an error. |
Exposed
| Member |
Type |
Description |
state |
InfiniteScrollState |
Current loading state. |
busy |
boolean |
Whether a request is in flight. |
hasMore |
boolean |
Whether more items can be requested. |
error |
unknown |
Rejection reason of the last failed loader call, or undefined. |
element |
HTMLButtonElement | null |
Rendered native button. |
Source: src/families/data/infinite-scroll/infinite-scroll-root.vue
Props
| Prop |
Type |
Default |
Description |
id |
string | null |
undefined |
Consumer-owned root id. null and undefined select a deterministic fallback. |
hasMore |
boolean |
true |
Whether more items can be requested. false settles the state to complete. |
loader |
InfiniteScrollLoader |
undefined |
Page loader called for every accepted request. A returned promise keeps the state loading until it settles; a rejection moves to error. |
loading |
boolean |
undefined |
Controlled in-flight flag for consumers that load through the loadMore emit. undefined derives the flag from loader. |
disabled |
boolean |
false |
Suppress every request while keeping rendered items intact. |
scrollRoot |
InfiniteScrollObserverRoot |
"viewport" |
Intersection root: the viewport, or this root element as the scroll container. |
rootMargin |
string |
"256px" |
Margin around the intersection root, so pages load before the sentinel is visible. |
feed |
boolean |
false |
Render the WAI-ARIA feed pattern: role="feed", aria-busy, and PageUp/PageDown focus movement between InfiniteScrollItem articles. |
total |
number |
undefined |
Total item count announced as aria-setsize. undefined announces -1 (unknown). |
ariaLabel |
string |
undefined |
Accessible feed name when no visible label or aria-labelledby supplies one. |
ariaLabelledby |
string |
undefined |
Space-separated ids that label the feed. |
Events
| Event |
Payload |
Description |
loadMore |
[trigger: InfiniteScrollTrigger] |
Fired for every accepted request before loader runs. |
error |
[reason: unknown, trigger: InfiniteScrollTrigger] |
Fired after a loader promise rejects. |
stateChange |
[state: InfiniteScrollState, previous: InfiniteScrollState] |
Fired after every distinct loading-state transition. |
Slots
| Slot |
Slot props |
Description |
default |
InfiniteScrollSlotState |
Items, sentinel, load-more control, and status. Receives the loading state. |
Exposed
| Member |
Type |
Description |
busy |
— |
|
element |
Readonly<ShallowRef<HTMLElement | null>> |
Template ref to the rendered root element. |
error |
— |
|
hasMore |
— |
|
loadMore |
— |
|
refresh |
— |
|
retry |
— |
|
state |
— |
|
Source: src/families/data/infinite-scroll/infinite-scroll-root.vue
Props
| Prop |
Type |
Default |
Description |
id |
string | null |
undefined |
Consumer-owned root id. null and undefined select a deterministic fallback. |
hasMore |
boolean |
true |
Whether more items can be requested. false settles the state to complete. |
loader |
InfiniteScrollLoader |
undefined |
Page loader called for every accepted request. A returned promise keeps the state loading until it settles; a rejection moves to error. |
loading |
boolean |
undefined |
Controlled in-flight flag for consumers that load through the loadMore emit. undefined derives the flag from loader. |
disabled |
boolean |
false |
Suppress every request while keeping rendered items intact. |
scrollRoot |
InfiniteScrollObserverRoot |
"viewport" |
Intersection root: the viewport, or this root element as the scroll container. |
rootMargin |
string |
"256px" |
Margin around the intersection root, so pages load before the sentinel is visible. |
feed |
boolean |
false |
Render the WAI-ARIA feed pattern: role="feed", aria-busy, and PageUp/PageDown focus movement between InfiniteScrollItem articles. |
total |
number |
undefined |
Total item count announced as aria-setsize. undefined announces -1 (unknown). |
ariaLabel |
string |
undefined |
Accessible feed name when no visible label or aria-labelledby supplies one. |
ariaLabelledby |
string |
undefined |
Space-separated ids that label the feed. |
Events
| Event |
Payload |
Description |
loadMore |
[trigger: InfiniteScrollTrigger] |
Fired for every accepted request before loader runs. |
error |
[reason: unknown, trigger: InfiniteScrollTrigger] |
Fired after a loader promise rejects. |
stateChange |
[state: InfiniteScrollState, previous: InfiniteScrollState] |
Fired after every distinct loading-state transition. |
Slots
| Slot |
Slot props |
Description |
default |
InfiniteScrollSlotState |
Items, sentinel, load-more control, and status. Receives the loading state. |
Exposed
| Member |
Type |
Description |
state |
InfiniteScrollState |
Current loading state. |
busy |
boolean |
Whether a request is in flight. |
hasMore |
boolean |
Whether more items can be requested. |
error |
unknown |
Rejection reason of the last failed loader call, or undefined. |
element |
HTMLDivElement | null |
Rendered root element. |
loadMore |
() => boolean |
Request the next page like the sentinel would. Reports whether a request started. |
retry |
() => boolean |
Clear an error and request again. Reports whether a request started. |
refresh |
() => void |
Re-check sentinel visibility, e.g. after a layout change without a state change. |
Source: src/families/data/infinite-scroll/infinite-scroll-sentinel.vue
Slots
| Slot |
Slot props |
Description |
default |
InfiniteScrollSlotState |
Optional sentinel content such as a spinner. Receives the loading state. |
Exposed
| Member |
Type |
Description |
element |
HTMLDivElement | null |
Rendered sentinel element. |
intersecting |
boolean |
Whether the sentinel currently intersects its root. |
Source: src/families/data/infinite-scroll/infinite-scroll-status.vue
Slots
| Slot |
Slot props |
Description |
default |
InfiniteScrollSlotState |
Announcement text such as "Loading more results" or "All results loaded". The polite live region is always rendered so changes are announced. |
Exposed
| Member |
Type |
Description |
state |
InfiniteScrollState |
Current loading state. |
busy |
boolean |
Whether a request is in flight. |
hasMore |
boolean |
Whether more items can be requested. |
error |
unknown |
Rejection reason of the last failed loader call, or undefined. |
element |
HTMLDivElement | null |
Rendered live region. |
Behavior
Normative state x input -> outcome table for infinite-scroll-root.vue,
infinite-scroll-sentinel.vue, infinite-scroll-load-more.vue,
infinite-scroll-status.vue, and infinite-scroll-item.vue
(@vizejs/ui/infinite-scroll). Every row is proven by the named test.
The loading state is disabled > loading > complete > error > idle in priority
order. A request is accepted only while idle, or while error from the
load-more button or the imperative API, so a failing page never loops through
the sentinel. loader promises drive loading automatically; consumers that
fetch through the loadMore emit own the busy flag through the loading prop.
| ID |
State |
Input |
Outcome |
Evidence |
| IS1 |
idle |
render |
root carries a deterministic id, aria-busy="false", feed semantics when feed, observed hidden sentinel, wired button, live status |
renders sentinel, load-more, status, and feed semantics with deterministic wiring |
| IS2 |
idle |
sentinel intersects |
emits loadMore("sentinel") once, runs loader, and stays loading (aria-busy="true", button disabled) until the promise settles |
the sentinel requests once per intersection and the loader promise drives loading |
| IS3 |
loading -> idle |
loader resolves |
returns to idle and re-observes the sentinel, so a still-visible sentinel requests the next page |
the sentinel requests once per intersection and the loader promise drives loading |
| IS4 |
controlled loading |
prop changes / hasMore=false |
the parent owns the busy flag; complete hides and disables the button and ignores the sentinel |
controlled loading and hasMore settle state without a loader |
| IS5 |
loading |
loader rejects or throws |
moves to error, emits error(reason, trigger), exposes the reason, and ignores the sentinel until the button or API retries |
loader failures stop automatic loading until an explicit retry |
| IS6 |
idle |
button click |
emits click first; preventDefault() keeps state unchanged, otherwise requests with trigger button |
the load-more button requests pages and honors preventDefault |
| IS7 |
disabled |
intersection / click / API |
every request is refused |
disabled roots suppress every request |
| IS8 |
scrollRoot="self" |
mount / refresh() |
the sentinel observes against the root element with rootMargin; refresh() re-observes |
self scroll roots observe the sentinel against the root element |
| IS9 |
sentinel |
intersection batches |
exposes the latest intersection state and ignores empty batches |
the sentinel exposes its intersection state |
| IS10 |
feed |
PageDown / PageUp |
moves focus between articles; PageDown on the last article requests the next page; other keys and non-feed roots are untouched |
feed PageDown and PageUp move focus between articles and request at the end |
| IS11 |
exposed instance |
read / loadMore() / retry() |
exposes state, busy, hasMore, error, element; retry() requires error; unknown totals announce aria-setsize="-1" |
exposes typed state and imperative paging controls |
| IS12 |
missing provider |
setup |
compound parts fail closed with the shared context diagnostic |
compound parts require a matching root provider |
| IS13 |
SSR |
isolated requests |
markup is byte-identical, including generated ids, feed positions, and the live region |
renders byte-identical infinite scroll markup across isolated SSR requests |
| IS14 |
SSR / hydration |
hydrate |
server markup hydrates without warnings or node replacement |
hydrates infinite scroll markup without warnings or node replacement |
| IS15 |
types |
compile |
states, triggers, observer roots, slot state, and exposes are closed and read-only |
infinite-scroll.types.test-d.ts |
Without IntersectionObserver the sentinel stays inert and the load-more button
remains the paging control. The intersection root and margin are read when the
sentinel mounts. Control+Home/End feed shortcuts are optional in the APG pattern
and are not implemented.