<div id="vertical" class="h-100 w-full flex items-center justify-center font-sans">
  <div class="bg-white rounded shadow p-6 m-4">
    <h1 class="titlecase font-bold">Verical infinity scrolling </h1>
    <i>Page Index: <b id="pageIndex"></b> </i>
    <i hidden id="loader">Loading...</i>
    <div id="todo-list" class="overflow-auto max-h-60">
      <p id="todo-placeholder" class="p-1 m-1 border" hidden></p>
    </div>
  </div>
</div>
const {
  BehaviorSubject,
  Observable,
  Subject,
  debounceTime,
  exhaustMap,
  filter,
  finalize,
  fromEvent,
  startWith,
  takeUntil,
  tap,
  from,
  pipe,
  ObservableInput
} = rxjs;

type InfinityScrollDirection = "horizontal" | "vertical";

export interface InfinityScrollOptions {
  /**
   * The element that is scrollable.
   */
  element: HTMLElement;
  /**
   * A BehaviorSubject that emits true when loading and false when not loading.
   */
  loading: BehaviorSubject<boolean>;
  /**
   * Indicates how far from the end of the scrollable element the user must be before the loadFn is called.
   */
  threshold: number;
  /**
   * The initial page index to start loading from.
   */
  initialPageIndex: number;
  /**
   * The direction of the scrollable element.
   */
  scrollDirection?: InfinityScrollDirection;
  /**
   * The function that is called when the user scrolls to the end of the scrollable element with respect to the threshold.
   */
  loadFn: (result: InfinityScrollResult) => Observable<any>;
}

export interface InfinityScrollResult {
  /**
   * The next page index.
   */
  pageIndex: number;
  /**
   * A Subject that can be used to indicate that more data should be loaded.
   *
   * Calling `complete()` on this Subject will stop loading more data.
   */
  loadMore: Subject<void>;
}

function infinityScroll(options: InfinityScrollOptions) {
  const ensureScrolled = pipe(
    filter(() => !options.loading.value), // ignore scroll event if already loading
    debounceTime(100), // debounce scroll event to prevent lagginess on heavy scroll pages
    // filter(() => isScrollable(options.element, options.scrollDirection)),
    filter(() => {
      const remainingDistance = calculateRemainingDistance(
        options.element,
        options.scrollDirection
      );
      return remainingDistance <= options.threshold;
    })
  );

  const fetchData = pipe(
    exhaustMap((_, index) => {
      options.loading.next(true);
      return options.loadFn({
        pageIndex: options.initialPageIndex + index
      });
    }),
    tap(() => options.loading.next(false)),
    // stop loading if error or explicitly completed (no more data)
    finalize(() => options.loading.next(false))
  );
  return fromEvent(options.element, "scroll").pipe(
    startWith(null),
    ensureScrolled,
    fetchData
  );
}

function calculateRemainingDistanceToBottom(element: HTMLElement) {
  const scrollPosition = element.scrollTop;
  const clientHeight = element.clientHeight;
  const totalHeight = element.scrollHeight;
  return totalHeight - (scrollPosition + clientHeight);
}

function calculateRemainingDistanceOnXAxis(element: HTMLElement): number {
  const scrollPosition = Math.abs(element.scrollLeft);
  const clientWidth = element.clientWidth;
  const totalWidth = element.scrollWidth;
  return totalWidth - (scrollPosition + clientWidth);
}

function calculateRemainingDistance(
  element: HTMLElement,
  direction: InfinityScrollDirection = "vertical"
) {
  if (direction === "horizontal") {
    return calculateRemainingDistanceOnXAxis(element);
  } else {
    return calculateRemainingDistanceToBottom(element);
  }
}

const loadingIndicator$ = new BehaviorSubject(false);
// Vertical Infinity Scrolling
const elements = getElements();
const infinityScroll$ = infinityScroll({
  initialPageIndex: 1,
  threshold: 50,
  element: elements.listEl,
  loading: loadingIndicator$,
  loadFn: (result) => {
    elements.pageIndexEl.textContent = result.pageIndex;
    const dataEndpoint = `https://jsonplaceholder.typicode.com/todos?_start${result.pageIndex}&_limit=10`;
    return fetch(dataEndpoint).then((res) => res.json());
  }
});

infinityScroll$.subscribe((data) => {
  data.forEach((todo) => appendTodoEl(elements, todo));
});

loadingIndicator$.subscribe((loading) => (elements.loaderEl.hidden = !loading));

interface Todo {
  title: string;
}

// Utility function to get element from DOM
function getElements() {
  const mainId = "vertical";
  const listEl = document.querySelector(`#${mainId} #todo-list`);
  const placeholderEl = document.querySelector(`#${mainId} #todo-placeholder`);
  const pageIndexEl = document.querySelector(`#${mainId} #pageIndex`);
  const loaderEl = document.querySelector(`#${mainId} #loader`);
  return {
    listEl,
    placeholderEl,
    pageIndexEl,
    loaderEl
  };
}

// Utility function to create Todo element
function appendTodoEl(elements: Record<string, HTMLElement>, todo: Todo) {
  const newEl = elements.placeholderEl.cloneNode();
  newEl.hidden = false;
  newEl.textContent = todo.title;
  elements.listEl.appendChild(newEl);
}
View Compiled
Run Pen

External CSS

  1. https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.8.1/rxjs.umd.min.js