Infinite Scroll
infinite-scroll ui 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
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/infinite-scroll.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/infinite-scroll.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/infinite-scroll.json $ bunx 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