UIPackage
Menu

Infinite Scroll

infinite-scroll ui
Edit on GitHub

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

$ npx shadcn@latest add https://uipkge.dev/r/react/infinite-scroll.json
Named registry: 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