DEV Community

Cover image for Shadcn infinite scroll example
Bojan Stanojevic
Bojan Stanojevic

Posted on

Shadcn infinite scroll example

In this post, let's explore how you can add infinite scroll functionality to your app using shadcn. While infinite scroll isn't available by default in the shadcn/ui library, it's easy to implement yourself. Whether or not this feature will be added in the future is unclear, but for now, you can seamlessly add it to your project.

Step 1: Create the Infinite Scroll Component

Start by adding an infinite scroll component in the components/ui folder. Name the file infinite-scroll.tsx. This will serve as the container that listens for scroll events and loads additional content when the user reaches the end of the viewport.

import * as React from 'react';

interface InfiniteScrollProps {
  isLoading: boolean;
  hasMore: boolean;
  next: () => unknown;
  threshold?: number;
  root?: Element | Document | null;
  rootMargin?: string;
  reverse?: boolean;
  children?: React.ReactNode;
}

export default function InfiniteScroll({
  isLoading,
  hasMore,
  next,
  threshold = 1,
  root = null,
  rootMargin = '0px',
  reverse,
  children,
}: InfiniteScrollProps) {
  const observer = React.useRef<IntersectionObserver>();
  // This callback ref will be called when it is dispatched to an element or detached from an element,
  // or when the callback function changes.
  const observerRef = React.useCallback(
    (element: HTMLElement | null) => {
      let safeThreshold = threshold;
      if (threshold < 0 || threshold > 1) {
        console.warn(
          'threshold should be between 0 and 1. You are exceed the range. will use default value: 1',
        );
        safeThreshold = 1;
      }
      // When isLoading is true, this callback will do nothing.
      // It means that the next function will never be called.
      // It is safe because the intersection observer has disconnected the previous element.
      if (isLoading) return;

      if (observer.current) observer.current.disconnect();
      if (!element) return;

      // Create a new IntersectionObserver instance because hasMore or next may be changed.
      observer.current = new IntersectionObserver(
        (entries) => {
          if (entries[0].isIntersecting && hasMore) {
            next();
          }
        },
        { threshold: safeThreshold, root, rootMargin },
      );
      observer.current.observe(element);
    },
    [hasMore, isLoading, next, threshold, root, rootMargin],
  );

  const flattenChildren = React.useMemo(() => React.Children.toArray(children), [children]);

  return (
    <>
      {flattenChildren.map((child, index) => {
        if (!React.isValidElement(child)) {
          process.env.NODE_ENV === 'development' &&
            console.warn('You should use a valid element with InfiniteScroll');
          return child;
        }

        const isObserveTarget = reverse ? index === 0 : index === flattenChildren.length - 1;
        const ref = isObserveTarget ? observerRef : null;
        // @ts-ignore ignore ref type
        return React.cloneElement(child, { ref });
      })}
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

Step 2: Set Up the Viewport

You'll want to ensure that the infinite scroll viewport is set up correctly so that the component tracks when the user has scrolled to the bottom. You can do this by leveraging event listeners or hooks to detect the position of the viewport relative to the content.

Step 3: Create an Example Page

Next, create an example page, and copy the code provided below into this page. Here, you'll import your InfiniteScroll component and make sure to install the lucide-react library for the loading spinner, or alternatively, use any icon library of your choice. This will handle the loading state while fetching new content.

'use client';
import React from 'react';
import InfiniteScroll from '@/components/ui/infinite-scroll';
import { Loader2 } from 'lucide-react';

interface DummyProductResponse {
  products: DummyProduct[];
  total: number;
  skip: number;
  limit: number;
}

interface DummyProduct {
  id: number;
  title: string;
  price: string;
}

const Product = ({ product }: { product: DummyProduct }) => {
  return (
    <div className="flex w-full flex-col gap-2 rounded-lg border-2 border-gray-200 p-2">
      <div className="flex gap-2">
        <div className="flex flex-col justify-center gap-1">
          <div className="font-bold text-primary">
            {product.id} - {product.title}
          </div>
          <div className="text-sm text-muted-foreground">{product.price}</div>
        </div>
      </div>
    </div>
  );
};

const InfiniteScrollDemo = () => {
  const [page, setPage] = React.useState(0);
  const [loading, setLoading] = React.useState(false);
  const [hasMore, setHasMore] = React.useState(true);
  const [products, setProducts] = React.useState<DummyProduct[]>([]);

  const next = async () => {
    setLoading(true);

    /**
     * Intentionally delay the search by 800ms before execution so that you can see the loading spinner.
     * In your app, you can remove this setTimeout.
     **/
    setTimeout(async () => {
      const res = await fetch(
        `https://dummyjson.com/products?limit=3&skip=${3 * page}&select=title,price`,
      );
      const data = (await res.json()) as DummyProductResponse;
      setProducts((prev) => [...prev, ...data.products]);
      setPage((prev) => prev + 1);

      // Usually your response will tell you if there is no more data.
      if (data.products.length < 3) {
        setHasMore(false);
      }
      setLoading(false);
    }, 800);
  };
  return (
    <div className="max-h-[300px] w-full  overflow-y-auto px-10">
      <div className="flex w-full flex-col items-center  gap-3">
        {products.map((product) => (
          <Product key={product.id} product={product} />
        ))}
        <InfiniteScroll hasMore={hasMore} isLoading={loading} next={next} threshold={1}>
          {hasMore && <Loader2 className="my-4 h-8 w-8 animate-spin" />}
        </InfiniteScroll>
      </div>
    </div>
  );
};

export default InfiniteScrollDemo;

Enter fullscreen mode Exit fullscreen mode

Step 4: Load Data Dynamically

In the example page, we're using some dummy JSON data, but this setup can easily be extended to handle any type of data source you're working with. The infinite scroll viewport will detect when users reach the bottom and dynamically load more data, ensuring a smooth, seamless scrolling experience.

Final Thoughts

And that's it! With this shadcn infinite scroll viewport example, you don't need any complex dependencies, and the setup is quick and easy. This approach is flexible and can be adapted to various use cases in your app.

If you enjoyed this post I'd love it if you could give me a follow on Twitter by clicking on the button below, or browse agency website - Kodawarians to learn more.
Dellboyan Twitter

Top comments (0)