Infinite Scroll
Intersection Observer-powered container that fires a load event when the user scrolls near the bottom, with built-in loading spinner and end-of-list state.
<arc-infinite-scroll> Overview
>
InfiniteScroll wraps your content in a feed container and watches for when the user approaches the bottom using an Intersection Observer sentinel element. When the sentinel enters the viewport (plus a configurable `threshold` margin), the component fires an `arc-load-more` event, signalling your application to fetch and append the next batch of items. This eliminates the need for manual scroll listeners or pagination button clicks.
While data is being fetched, set the `loading` prop to `true` to display a centered spinner in the footer area. The component automatically suppresses additional `arc-load-more` events while loading is active, preventing duplicate fetch requests. Once your data arrives, reset `loading` to `false` and the sentinel resumes observation.
When all data has been loaded, set the `finished` prop to `true`. The sentinel is removed and replaced with a "No more items" text indicator, and the Intersection Observer is disconnected. The container uses `aria-busy` to communicate loading state to assistive technologies. If your items are self-contained entries (e.g. posts), slot them as `<article>` elements and add `role="feed"` to the component to opt into the ARIA feed pattern.Guidelines
When to use
- Set `loading` to `true` immediately when you begin fetching data, and reset it to `false` when data arrives
- Set `finished` to `true` when the API indicates no more pages remain, so the observer disconnects
- Use a reasonable `threshold` (100-300px) so content loads before users hit the bottom and see a gap
- Place InfiniteScroll inside a scrollable parent or let the page itself be the scroll container
- Append new items as direct children of the component — they render in the default slot
When not to use
- Do not leave `loading` as `true` indefinitely — this blocks further load events and leaves the spinner visible
- Do not use InfiniteScroll for small, fixed datasets — standard pagination or a simple list is more appropriate
- Do not set `threshold` to 0 — the user will see a flash of empty space before content loads
- Do not nest multiple InfiniteScroll components — their observers may conflict
- Avoid using InfiniteScroll without providing a way to reach footer content, since it pushes the footer down continuously
Features
- Intersection Observer-based sentinel that fires `arc-load-more` when the user nears the content bottom
- Configurable `threshold` prop (in pixels) to control how far in advance the load event triggers
- Built-in `arc-spinner` displayed in the footer when `loading` is `true`
- Automatic suppression of duplicate load events while loading is in progress
- End-of-list state via `finished` prop — removes the sentinel and shows "No more items" text
- ARIA `aria-busy` for accessible loading communication
- Observer automatically disconnects and reconnects when `finished` or `disabled` change
- Disabled state at 40% opacity with pointer events blocked
Preview
Usage
This component requires JavaScript. No pure HTML/CSS version is available — use the Web Component directly or a framework wrapper.
<arc-infinite-scroll id="feed" threshold="200">
<!-- Items appended here -->
</arc-infinite-scroll>
<script>
const feed = document.getElementById('feed');
let page = 1;
feed.addEventListener('arc-load-more', async () => {
feed.loading = true;
const items = await fetchPage(page++);
items.forEach(item => feed.appendChild(createItemEl(item)));
feed.loading = false;
if (!items.length) feed.finished = true;
});
</script> import { InfiniteScroll } from '@arclux/arc-ui-react';
const [items, setItems] = useState(initialItems);
const [loading, setLoading] = useState(false);
const [finished, setFinished] = useState(false);
async function loadMore() {
setLoading(true);
const next = await fetchNextPage();
setItems(prev => [...prev, ...next]);
setLoading(false);
if (!next.length) setFinished(true);
}
<InfiniteScroll loading={loading} finished={finished} onArcLoadMore={loadMore}>
{items.map(item => <div key={item.id}>{item.name}</div>)}
</InfiniteScroll> <script setup>
import { ref } from 'vue';
import { InfiniteScroll } from '@arclux/arc-ui-vue';
const items = ref(initialItems);
const loading = ref(false);
const finished = ref(false);
async function loadMore() {
loading.value = true;
const next = await fetchNextPage();
items.value.push(...next);
loading.value = false;
if (!next.length) finished.value = true;
}
</script>
<template>
<InfiniteScroll :loading="loading" :finished="finished" @arc-load-more="loadMore">
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
</InfiniteScroll>
</template> <script>
import { InfiniteScroll } from '@arclux/arc-ui-svelte';
let items = $state(initialItems);
let loading = $state(false);
let finished = $state(false);
async function loadMore() {
loading = true;
const next = await fetchNextPage();
items = [...items, ...next];
loading = false;
if (!next.length) finished = true;
}
</script>
<InfiniteScroll {loading} {finished} on:arc-load-more={loadMore}>
{#each items as item (item.id)}
<div>{item.name}</div>
{/each}
</InfiniteScroll> import { Component } from '@angular/core';
import { InfiniteScroll } from '@arclux/arc-ui-angular';
@Component({
imports: [InfiniteScroll],
template: `
<arc-infinite-scroll
[loading]="loading"
[finished]="finished"
(arc-load-more)="loadMore()"
>
<div *ngFor="let item of items">{{ item.name }}</div>
</arc-infinite-scroll>
`,
})
export class FeedComponent {
items = [];
loading = false;
finished = false;
async loadMore() {
this.loading = true;
const next = await this.fetchNextPage();
this.items.push(...next);
this.loading = false;
if (!next.length) this.finished = true;
}
} import { createSignal, For } from 'solid-js';
import { InfiniteScroll } from '@arclux/arc-ui-solid';
const [items, setItems] = createSignal(initialItems);
const [loading, setLoading] = createSignal(false);
const [finished, setFinished] = createSignal(false);
async function loadMore() {
setLoading(true);
const next = await fetchNextPage();
setItems(prev => [...prev, ...next]);
setLoading(false);
if (!next.length) setFinished(true);
}
<InfiniteScroll loading={loading()} finished={finished()} onArcLoadMore={loadMore}>
<For each={items()}>{item => <div>{item.name}</div>}</For>
</InfiniteScroll> import { useState } from 'preact/hooks';
import { InfiniteScroll } from '@arclux/arc-ui-preact';
const [items, setItems] = useState(initialItems);
const [loading, setLoading] = useState(false);
const [finished, setFinished] = useState(false);
async function loadMore() {
setLoading(true);
const next = await fetchNextPage();
setItems(prev => [...prev, ...next]);
setLoading(false);
if (!next.length) setFinished(true);
}
<InfiniteScroll loading={loading} finished={finished} onArcLoadMore={loadMore}>
{items.map(item => <div key={item.id}>{item.name}</div>)}
</InfiniteScroll> API
-
thresholdnumber200 - Distance in pixels from the bottom of the content at which `arc-load-more` fires. Controls how eagerly new data is requested.
-
loadingbooleanfalse - When true, displays a spinner in the footer and suppresses additional `arc-load-more` events.
-
finishedbooleanfalse - When true, disconnects the observer and displays "No more items" text in the footer.
-
disabledbooleanfalse - Disables the component, disconnects the observer, and reduces opacity to 40%.
Events
-
arc-load-more - Fired when the scroll sentinel enters the viewport, signaling more content should load