Infinite Scroll
Load more content as the user scrolls to the end of a list.
Place a sentinel at the end of a list; as it nears the viewport it fires and you load the next batch. A pending guard suppresses double-fires while a batch loads, and if the sentinel is still on screen afterwards (a short list that did not fill the viewport) it loads again. Mark it exhausted to stop observing and reveal an optional end slot.
Installation
npx touchcn add infinite-scroll
Usage
<tcn-infinite-scroll #infinite="tcnInfiniteScroll" [disabled]="exhausted()" (loadMore)="onLoadMore(infinite)">
<span end>You're all caught up</span>
</tcn-infinite-scroll>import { TcnInfiniteScroll } from '@/components/ui/infinite-scroll';
<TcnInfiniteScroll onLoadMore={onLoadMore} disabled={exhausted} end={<span>You're all caught up</span>} /><script setup lang="ts">
import { TcnInfiniteScroll } from '@/components/ui/infinite-scroll';
</script>
<template>
<TcnInfiniteScroll :on-load-more="onLoadMore" :disabled="exhausted">
<template #end>
<span>You're all caught up</span>
</template>
</TcnInfiniteScroll>
</template>On Angular, load the batch when loadMore fires and call complete() (via the template ref) to release the guard. On React, return a Promise from onLoadMore — the guard holds until it settles.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
loadMore / onLoadMore |
() => void | Promise<void> |
— | Fires when the sentinel nears the viewport. |
distance |
string |
'15%' |
Bottom rootMargin — how early to fire. |
disabled |
boolean |
false |
Stops observing and reveals the end slot (exhausted). |
end / [end] |
slot | — | Content shown once the list is exhausted. |
complete() |
method (Angular) | — | Releases the guard after a batch; React uses the Promise. |
Source · Angular
export * from './tcn-infinite-scroll';import { Component, inject } from '@angular/core';
import { TcnInfiniteScrollDirective } from '@touchcn/angular/infinite-scroll';
/**
* Infinite-scroll sentinel. Place it at the end of the list; as it nears the
* viewport it emits `loadMore`. Load the next batch in that handler, then call
* `complete()` (via a template ref). Set `disabled` once the list is exhausted
* to stop observing and reveal the projected `[end]` slot.
*
* All behaviour — the IntersectionObserver rooted on the resolved scroll
* container, the pending guard and the `data-state` — lives in the engine
* `TcnInfiniteScrollDirective`; this component owns only markup and classes.
* The loading row reuses the platform spinner visuals.
*/
@Component({
selector: 'tcn-infinite-scroll',
exportAs: 'tcnInfiniteScroll',
hostDirectives: [
{
directive: TcnInfiniteScrollDirective,
inputs: ['disabled', 'distance'],
outputs: ['loadMore'],
},
],
template: `
<div class="tcn-infinite-loading">
<span class="tcn-spinner size-7" role="status" aria-label="Loading more">
<span class="tcn-spinner-ios if-ios" aria-hidden="true">
@for (spoke of spokes; track $index) {
<span class="tcn-spinner-spoke"></span>
}
</span>
<svg class="tcn-spinner-md if-md" viewBox="0 0 48 48" aria-hidden="true">
<circle class="tcn-spinner-arc" cx="24" cy="24" r="20" />
</svg>
</span>
</div>
<div class="tcn-infinite-end">
<ng-content select="[end]" />
</div>
`,
host: { class: 'tcn-infinite-scroll' },
})
export class TcnInfiniteScroll {
private readonly infinite = inject(TcnInfiniteScrollDirective);
protected readonly spokes = Array.from({ length: 8 });
/** Signals the current batch has loaded and the sentinel may fire again. */
complete(): void {
this.infinite.complete();
}
}Source · React
export * from './tcn-infinite-scroll';import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { cn } from '@touchcn/core';
import { useInfiniteScroll } from '@touchcn/react';
export interface TcnInfiniteScrollProps extends ComponentPropsWithoutRef<'div'> {
/**
* Loads the next batch. Return a Promise (or async) — the guard holds until
* it resolves, then re-checks whether more is needed to fill the viewport.
*/
onLoadMore: () => void | Promise<void>;
/** Stops observing and reveals the `end` slot (list exhausted). */
disabled?: boolean;
/** Bottom `rootMargin` — how early to fire (e.g. `'15%'` or `'120px'`). */
distance?: string;
/** Shown when `disabled` (the end of the list has been reached). */
end?: ReactNode;
}
/**
* Infinite-scroll sentinel. Place it at the end of the list; as it nears the
* viewport it runs `onLoadMore`. Set `disabled` once the list is exhausted to
* stop observing and reveal the `end` slot.
*
* All behaviour — the IntersectionObserver rooted on the resolved scroll
* container, the pending guard and the `data-state` — lives in the engine
* `useInfiniteScroll` hook; this component owns only markup and classes. The
* loading row reuses the platform spinner visuals.
*/
export function TcnInfiniteScroll({ onLoadMore, disabled, distance, end, className, ...props }: TcnInfiniteScrollProps) {
const { sentinelRef, state } = useInfiniteScroll({ onLoadMore, disabled, distance });
return (
<div ref={sentinelRef} data-state={state} className={cn('tcn-infinite-scroll', className)} {...props}>
<div className="tcn-infinite-loading">
<span className="tcn-spinner size-7" role="status" aria-label="Loading more">
<span className="tcn-spinner-ios if-ios" aria-hidden="true">
{Array.from({ length: 8 }).map((_, index) => (
<span key={index} className="tcn-spinner-spoke" />
))}
</span>
<svg className="tcn-spinner-md if-md" viewBox="0 0 48 48" aria-hidden="true">
<circle className="tcn-spinner-arc" cx="24" cy="24" r="20" />
</svg>
</span>
</div>
<div className="tcn-infinite-end">{end}</div>
</div>
);
}Source · Vue
<script setup lang="ts">
import { useInfiniteScroll } from '@touchcn/vue';
const props = defineProps<{
/**
* Loads the next batch. Return a Promise (or async) — the guard holds until
* it resolves, then re-checks whether more is needed to fill the viewport.
*/
onLoadMore: () => void | Promise<void>;
/** Stops observing and reveals the `end` slot (list exhausted). */
disabled?: boolean;
/** Bottom `rootMargin` — how early to fire (e.g. `'15%'` or `'120px'`). */
distance?: string;
}>();
/**
* Infinite-scroll sentinel. Place it at the end of the list; as it nears the
* viewport it runs `onLoadMore`. Set `disabled` once the list is exhausted to
* stop observing and reveal the `end` slot.
*
* All behaviour — the IntersectionObserver rooted on the resolved scroll
* container, the pending guard and the `data-state` — lives in the engine
* `useInfiniteScroll` hook; this component owns only markup and classes. The
* loading row reuses the platform spinner visuals.
*/
const { setSentinel, state } = useInfiniteScroll(() => ({
onLoadMore: props.onLoadMore,
disabled: props.disabled,
distance: props.distance,
}));
</script>
<template>
<div :ref="setSentinel" :data-state="state" class="tcn-infinite-scroll">
<div class="tcn-infinite-loading">
<span class="tcn-spinner size-7" role="status" aria-label="Loading more">
<span class="tcn-spinner-ios if-ios" aria-hidden="true">
<span v-for="i in 8" :key="i" class="tcn-spinner-spoke" />
</span>
<svg class="tcn-spinner-md if-md" viewBox="0 0 48 48" aria-hidden="true">
<circle class="tcn-spinner-arc" cx="24" cy="24" r="20" />
</svg>
</span>
</div>
<div class="tcn-infinite-end"><slot name="end" /></div>
</div>
</template>export { default as TcnInfiniteScroll } from './TcnInfiniteScroll.vue';