Infinite Scroll
infinite-scroll ui Load-more-on-scroll sentinel. Calls an onLoadMore callback when the user scrolls near the bottom (or top, in reverse mode). Supports a window or element scroll target, distance threshold, loading/hasMore/disabled gating, and slots for custom loading and end-of-list states.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://uipkge.dev/r/react/infinite-scroll.json $ npx shadcn@latest add https://uipkge.dev/r/react/infinite-scroll.json $ yarn dlx shadcn@latest add https://uipkge.dev/r/react/infinite-scroll.json $ bunx shadcn@latest add https://uipkge.dev/r/react/infinite-scroll.json npx shadcn@latest add @uipkge-react/infinite-scroll Installs to: components/ui/infinite-scroll/ Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
items new items in response to the `onLoadMore` callback. | T[] | — | optional |
hasMore When false, the sentinel never fires `onLoadMore` (end of data reached). | boolean | — | optional |
loading so duplicate loads are not emitted. | boolean | — | optional |
distance Distance (px) from the boundary at which `onLoadMore` fires. Larger = earlier. | number | — | optional |
scrollTarget ref (HTMLElement) or a CSS selector string to listen on a scrollable element instead. | 'window' | HTMLElement | string | — | optional |
reverse top edge and `onLoadMore` fires when the user scrolls near the top. | boolean | — | optional |
disabled Hard pause independent of `loading`/`hasMore`. | boolean | — | optional |
hideSpinner Hide the default loading spinner (use the `loadingSlot` prop instead). | boolean | — | optional |
loadingSlot Override the default loading spinner. Mirrors the Vue `loading` slot. | React.ReactNode | — | optional |
endSlot Override the default end-of-list message. Mirrors the Vue `end` slot. | React.ReactNode | — | optional |
children List contents. Mirrors the Vue default slot. | React.ReactNode | — | optional |
onLoadMore Fired when the user scrolls near the boundary. Vue `@load` -> React `onLoadMore`. | () => void | — | optional |
npm dependencies
Files installed (2)
-
components/ui/infinite-scroll/InfiniteScroll.tsx 6.1 kB
import * as React from 'react' import { Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' export interface InfiniteScrollProps<T = any> extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> { /** Rendered list. The component does not mutate it; the parent appends * new items in response to the `onLoadMore` callback. */ items?: T[] /** When false, the sentinel never fires `onLoadMore` (end of data reached). */ hasMore?: boolean /** True while the parent is fetching. While true the sentinel is paused * so duplicate loads are not emitted. */ loading?: boolean /** Distance (px) from the boundary at which `onLoadMore` fires. Larger = earlier. */ distance?: number /** Scroll container. `"window"` listens on the viewport; pass an element * ref (HTMLElement) or a CSS selector string to listen on a scrollable * element instead. */ scrollTarget?: 'window' | HTMLElement | string /** Reverse mode: prepend items at the top. The sentinel is anchored to the * top edge and `onLoadMore` fires when the user scrolls near the top. */ reverse?: boolean /** Hard pause independent of `loading`/`hasMore`. */ disabled?: boolean /** Hide the default loading spinner (use the `loadingSlot` prop instead). */ hideSpinner?: boolean /** Override the default loading spinner. Mirrors the Vue `loading` slot. */ loadingSlot?: React.ReactNode /** Override the default end-of-list message. Mirrors the Vue `end` slot. */ endSlot?: React.ReactNode /** List contents. Mirrors the Vue default slot. */ children?: React.ReactNode /** Fired when the user scrolls near the boundary. Vue `@load` -> React `onLoadMore`. */ onLoadMore?: () => void } type ScrollEl = HTMLElement | Window | null function InfiniteScrollInner<T>(props: InfiniteScrollProps<T>, ref: React.Ref<HTMLDivElement>) { const { className, hasMore = true, loading = false, distance = 0, scrollTarget = 'window', reverse = false, disabled = false, hideSpinner = false, loadingSlot, endSlot, children, onLoadMore, ...rest } = props const sentinelRef = React.useRef<HTMLDivElement | null>(null) // Latest props captured in a ref so the scroll handler never closes over // stale values (it is bound once per scroll-target change). const stateRef = React.useRef({ disabled, loading, hasMore, reverse, distance, onLoadMore }) stateRef.current = { disabled, loading, hasMore, reverse, distance, onLoadMore } const showSpinner = loading && !hideSpinner function getScrollElement(): ScrollEl { if (scrollTarget === 'window') return typeof window === 'undefined' ? null : window if (typeof scrollTarget === 'string') { if (typeof document === 'undefined') return null return (document.querySelector(scrollTarget) as HTMLElement | null) ?? window } return scrollTarget } function check() { const state = stateRef.current if (state.disabled || state.loading || !state.hasMore || !sentinelRef.current) return const sentinelEl = sentinelRef.current const sentinelRect = sentinelEl.getBoundingClientRect() const viewportH = window.innerHeight || document.documentElement.clientHeight const viewportW = window.innerWidth || document.documentElement.clientWidth if (state.reverse) { // Reverse: fire when the sentinel (anchored at top) approaches the top edge. if (sentinelRect.bottom >= -state.distance && sentinelRect.top <= viewportH) { state.onLoadMore?.() } } else { // Forward: fire when the sentinel approaches the bottom edge. if (sentinelRect.top <= viewportH + state.distance && sentinelRect.bottom >= -state.distance) { void viewportW state.onLoadMore?.() } } } // Bind the scroll listener; re-bind when the scroll target changes. React.useEffect(() => { const scrollEl = getScrollElement() if (!scrollEl) return const onScroll = () => check() scrollEl.addEventListener('scroll', onScroll, { passive: true }) // Fire once on mount so an initially-empty list starts loading immediately. check() return () => { scrollEl.removeEventListener('scroll', onScroll) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [scrollTarget]) // When a load completes (loading flips false) and there is still more data, // re-check in case the viewport is still larger than the content (short list). React.useEffect(() => { if (!loading && hasMore) { const raf = requestAnimationFrame(() => check()) return () => cancelAnimationFrame(raf) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [loading, hasMore]) return ( <div data-uipkge="" data-slot="infinite-scroll" className={cn('w-full', className)} ref={ref} {...rest}> {reverse ? ( <> {showSpinner ? ( <div data-slot="infinite-scroll-loading" className="flex w-full justify-center py-3"> {loadingSlot ?? <Loader2 className="text-muted-foreground size-5 animate-spin" aria-label="Loading" />} </div> ) : null} <div ref={sentinelRef} data-slot="infinite-scroll-sentinel" className="h-px w-full" aria-hidden="true" /> {children} </> ) : ( <> {children} <div ref={sentinelRef} data-slot="infinite-scroll-sentinel" className="h-px w-full" aria-hidden="true" /> {showSpinner ? ( <div data-slot="infinite-scroll-loading" className="flex w-full justify-center py-3"> {loadingSlot ?? <Loader2 className="text-muted-foreground size-5 animate-spin" aria-label="Loading" />} </div> ) : null} {!hasMore && !loading ? ( <div data-slot="infinite-scroll-end" className="text-muted-foreground w-full py-3 text-center text-xs"> {endSlot ?? 'No more items'} </div> ) : null} </> )} </div> ) } const InfiniteScroll = React.forwardRef(InfiniteScrollInner) as <T = any>( props: InfiniteScrollProps<T> & { ref?: React.Ref<HTMLDivElement> }, ) => React.ReactElement InfiniteScroll.displayName = 'InfiniteScroll' export { InfiniteScroll } -
components/ui/infinite-scroll/index.ts 0.1 kB
export { InfiniteScroll, type InfiniteScrollProps } from './InfiniteScroll'
Raw manifest: https://uipkge.dev/r/react/infinite-scroll.json