UIPackage
Menu

Infinite Scroll

infinite-scroll ui
Edit on GitHub

Load-more-on-scroll sentinel. Emits a `load` event 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 React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/infinite-scroll.json
Named registry: npx shadcn-vue@latest add @uipkge/infinite-scroll Installs to: app/components/ui/infinite-scroll/

Examples

Props

Name Type / Values Default Required
items

new items in response to the `load` event.

T[] () => [] as T[], hasMore: true, loading: false, distance:… optional
hasMore

When false, the sentinel never fires `load` (end of data reached).

boolean optional
loading

so duplicate loads are not emitted.

boolean optional
distance

Distance (px) from the boundary at which `load` fires. Larger = earlier.

number optional
scrollTarget

element instead.

'window' | HTMLElement | string optional
reverse

top edge and `load` 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 `loading` slot instead).

boolean optional
class HTMLAttributes['class'] optional

npm dependencies

Files installed (2)

  • app/components/ui/infinite-scroll/InfiniteScroll.vue 5 kB
    <script setup lang="ts" generic="T = any">
    import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
    import type { HTMLAttributes } from 'vue'
    import { Loader2 } from 'lucide-vue-next'
    import { cn } from '@/lib/utils'
    
    const props = withDefaults(
      defineProps<{
        /** Rendered list. The component does not mutate it; the parent appends
         *  new items in response to the `load` event. */
        items?: T[]
        /** When false, the sentinel never fires `load` (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 `load` 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 `load` 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 `loading` slot instead). */
        hideSpinner?: boolean
        class?: HTMLAttributes['class']
      }>(),
      {
        items: () => [] as T[],
        hasMore: true,
        loading: false,
        distance: 0,
        scrollTarget: 'window',
        reverse: false,
        disabled: false,
        hideSpinner: false,
      },
    )
    
    const emit = defineEmits<{
      (e: 'load'): void
    }>()
    
    const sentinel = ref<HTMLElement | null>(null)
    let scrollEl: HTMLElement | Window | null = null
    
    const showSpinner = computed(() => props.loading && !props.hideSpinner)
    
    function getScrollElement(): HTMLElement | Window | null {
      if (props.scrollTarget === 'window') return window
      if (typeof props.scrollTarget === 'string') {
        return (document.querySelector(props.scrollTarget) as HTMLElement | null) ?? window
      }
      return props.scrollTarget
    }
    
    function check() {
      if (props.disabled || props.loading || !props.hasMore || !sentinel.value) return
      const sentinelRect = sentinel.value.getBoundingClientRect()
      const viewportH = window.innerHeight || document.documentElement.clientHeight
      const viewportW = window.innerWidth || document.documentElement.clientWidth
      if (props.reverse) {
        // Reverse: fire when the sentinel (anchored at top) approaches the top edge.
        if (sentinelRect.bottom >= -props.distance && sentinelRect.top <= viewportH) {
          emit('load')
        }
      } else {
        // Forward: fire when the sentinel approaches the bottom edge.
        if (sentinelRect.top <= viewportH + props.distance && sentinelRect.bottom >= -props.distance) {
          void viewportW
          emit('load')
        }
      }
    }
    
    function onScroll() {
      check()
    }
    
    onMounted(() => {
      scrollEl = getScrollElement()
      scrollEl?.addEventListener('scroll', onScroll, { passive: true })
      // Fire once on mount so an initially-empty list starts loading immediately.
      check()
    })
    
    onBeforeUnmount(() => {
      scrollEl?.removeEventListener('scroll', onScroll)
    })
    
    // Re-bind the listener when the scroll target changes.
    watch(
      () => props.scrollTarget,
      () => {
        scrollEl?.removeEventListener('scroll', onScroll)
        scrollEl = getScrollElement()
        scrollEl?.addEventListener('scroll', onScroll, { passive: true })
        check()
      },
    )
    
    // 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).
    watch(
      () => props.loading,
      (now, was) => {
        if (was && !now && props.hasMore) {
          requestAnimationFrame(check)
        }
      },
    )
    </script>
    
    <template>
      <div data-uipkge data-slot="infinite-scroll" :class="cn('w-full', props.class)">
        <!-- Reverse mode: spinner + sentinel sit above the items so new rows
             prepend naturally without shifting the scroll position. -->
        <template v-if="reverse">
          <div v-if="showSpinner" data-slot="infinite-scroll-loading" class="flex w-full justify-center py-3">
            <slot name="loading">
              <Loader2 class="text-muted-foreground size-5 animate-spin" aria-label="Loading" />
            </slot>
          </div>
          <div ref="sentinel" data-slot="infinite-scroll-sentinel" class="h-px w-full" aria-hidden="true" />
          <slot :items="items" />
        </template>
    
        <template v-else>
          <slot :items="items" />
          <div ref="sentinel" data-slot="infinite-scroll-sentinel" class="h-px w-full" aria-hidden="true" />
          <div v-if="showSpinner" data-slot="infinite-scroll-loading" class="flex w-full justify-center py-3">
            <slot name="loading">
              <Loader2 class="text-muted-foreground size-5 animate-spin" aria-label="Loading" />
            </slot>
          </div>
          <div
            v-if="!hasMore && !loading"
            data-slot="infinite-scroll-end"
            class="text-muted-foreground w-full py-3 text-center text-xs"
          >
            <slot name="end">No more items</slot>
          </div>
        </template>
      </div>
    </template>
  • app/components/ui/infinite-scroll/index.ts 0.1 kB
    export { default as InfiniteScroll } from './InfiniteScroll.vue'

Raw manifest: https://uipkge.dev/r/vue/infinite-scroll.json