Blog post

Infinite scroll with Next.js, Framer Motion, and Supabase

2023-04-04

12 minute read

Infinite scroll with Next.js, Framer Motion, and Supabase

Every Launch Week, we strive to approach things differently as part of our Kaizen mindset. For Launch Week 7, we decided to add some twists to our popular ticket system. While we made several changes, this post will focus on one specific modification (we'll share more about the rest this week!).

Our goal was to feature all the amazing people who generated tickets on a single page. However, displaying them all at once could take a toll on the loading time and negatively affect the user experience. That's where lazy loading smaller subsets via infinite scrolling came in as the solution.

With infinite scrolling, users can effortlessly scroll through content while only loading the necessary data as they go. This not only enhances performance but also creates a seamless and enjoyable user experience.

In this post, we'll detail our process of implementing infinite scrolling using Next.js, Framer Motion, and Supabase.

Step 0. Load the first batch

First things first, let's install the dependencies we'll need.

npm install @supabase/supabase-js lodash framer-motion

Set up the supabase-js client and fetch the first 20 tickets through getServerSideProps so we don't start with an empty screen.

We'll assume our table in the db is called my_tickets_table.

import { useEffect, useState } from 'react'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient('supabase-url', 'supabase-key')

export default function TicketsPage({ tickets }) {
  const [loadedTickets, setLoadedTickets] = useState(tickets)

  return (
    <div>
      {loadedTickets.map((ticket, index) => (
        {/* We'll get to this part later */}
      ))}
    </div>
  )

export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
  const { data: tickets } = await supabase!
    .from('my_tickets_table')
    .select('*')
    .order('createdAt', { ascending: false })
    .limit(20)

  return {
    props: {
      tickets,
    },
  }
}

Step 1. Listen to the scroll

To detect if the user is scrolling we can listen to the window scroll event.

If you try to console.log() the event returned in the listener callback, you'll notice that it fires on every single pixel scrolled. We want to avoid triggering like crazy, so we'll use a lodash debounce function to limit how often we call this event to once every 200 milliseconds.

Here's what it looks like:

import { useEffect } from 'react'
import { debounce } from 'lodash'

const handleScroll = () => {
  // Do stuff when scrolling
}

useEffect(() => {
  const handleDebouncedScroll = debounce(() => handleScroll(), 200)
  window.addEventListener('scroll', handleDebouncedScroll)
  return () => {
    window.removeEventListener('scroll', handleDebouncedScroll)
  }
}, [])

Step 2. Check if the container intersects the viewport

Next, we want to check if the bottom of the tickets container intersects with the bottom of the viewport. We can use the getBoundingClientRect method to get the position of the container and then compare it with the height of the viewport.

import { useRef, useState } from 'react'

// ...

const containerRef = useRef(null)
const [offset, setOffset] = useState(1)
const [isInView, setIsInView] = useState(false)

const handleScroll = (container) => {
  if (containerRef.current && typeof window !== 'undefined') {
    const container = containerRef.current
    const { bottom } = container.getBoundingClientRect()
    const { innerHeight } = window
    setIsInView((prev) => bottom <= innerHeight)
  }
}

useEffect(() => {
  if (isInView) {
    loadMoreUsers(offset)
  }
}, [isInView])

return <div ref={containerRef}>{/* List of loaded tickets */}</div>

Step 3. Load tickets based on the offset

Now we can load more tickets based on the current offset. We'll use the range method from the supabase-js library to easily work with the pagination logic.

export default function TicketsPage() {
  const PAGE_COUNT = 20
  const [offset, setOffset] = useState(1)
  const [isLoading, setIsLoading] = useState(false)
  const [isInView, setIsInView] = useState(false)

  useEffect(() => {
    if (isInView) {
      loadMoreTickets(offset)
    }
  }, [isInView])

  const loadMoreTickets = async (offset: number) => {
    setIsLoading(true)
    // Every time we fetch, we want to increase
    // the offset to load fresh tickets
    setOffset((prev) => prev + 1)
    const { data: newTickets } = await fetchTickets(offset, PAGE_COUNT)
    // Merge new tickets with all previously loaded
    setLoadedTickets((prevTickets) => [...prevTickets, ...newTickets])
    setIsLoading(false)
  }

  const fetchTickets = async (offset, limit) => {
    const from = offset * PAGE_COUNT
    const to = from + PAGE_COUNT - 1

    const { data } = await supabase!
        .from('my_tickets_table')
        .select('*')
        .range(from, to)
        .order('createdAt', { ascending: false })


    return data
  }
}

Step 4. Animate the tickets

Now that we have our tickets loaded, we want to add some animation to make them pop like dominos as they appear on the screen. For this, we're going to use the Framer Motion library.

We'll wrap each ticket in a motion component and add a transition effect to stagger their appearance on the screen:

import { motion } from 'framer-motion'

// ...

{
  loadedTickets.map((ticket, index) => {
    // each ticket will be delayed based on it's index
    // but we need to subtract the delay from all the previously loaded tickets
    const recalculatedDelay = i >= PAGE_COUNT * 2 ? (i - PAGE_COUNT * (offset - 1)) / 15 : i / 15

    return (
      <motion.div
        key={ticket.id}
        initial={{ opacity: 0, y: 20 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{
          duration: 0.4,
          ease: [0.25, 0.25, 0, 1],
          delay: recalculatedDelay,
        }}
      >
        {/* Actual ticket component */}
      </motion.div>
    )
  })
}

With this code, each ticket will start with an opacity of 0 and a y position of 20. As it animates into view, it will fade in and move up to its final position. The delay for each ticket will be based on its index in the array, creating a nice staggered effect.

Step 5. Stop listening when finished

Once all the tickets have been loaded, we want to stop listening to the scroll event to avoid unnecessary requests. We can do this by setting a state variable called isLast to true whenever the length of the response will be less than PAGE_COUNT:

if (newTickets.length < PAGE_COUNT) {
  setIsLast(true)
}

We'll use this code to conditionally remove the event listener:

useEffect(() => {
  const handleDebouncedScroll = debounce(() => !isLast && handleScroll(), 200)
  window.addEventListener('scroll', handleScroll)
  return () => {
    window.removeEventListener('scroll', handleScroll)
  }
}, [])

Now, when the isLast state variable is true, the event listener will be removed and the component will stop listening to the scroll event.

Wrap up

That's it! We hope this post enabled you to build the next awesome infinite scroll.

Here's the complete code:

import { useEffect, useState, useRef } from 'react'
import { createClient } from '@supabase/supabase-js'
import { debounce } from 'lodash'
import { motion } from 'framer-motion'

const supabase = createClient('supabase-url', 'supabase-key')

export default function TicketsPage({ tickets }) {
  const PAGE_COUNT = 20
  const containerRef = useRef(null)
  const [loadedTickets, setLoadedTickets] = useState(tickets)
  const [offset, setOffset] = useState(1)
  const [isLoading, setIsLoading] = useState(false)
  const [isInView, setIsInView] = useState(false)

  const handleScroll = (container) => {
    if (containerRef.current && typeof window !== 'undefined') {
      const container = containerRef.current
      const { bottom } = container.getBoundingClientRect()
      const { innerHeight } = window
      setIsInView((prev) => bottom <= innerHeight)
    }
  }

  useEffect(() => {
    const handleDebouncedScroll = debounce(() => !isLast && handleScroll(), 200)
    window.addEventListener('scroll', handleScroll)
    return () => {
      window.removeEventListener('scroll', handleScroll)
    }
  }, [])

  useEffect(() => {
    if (isInView) {
      loadMoreTickets(offset)
    }
  }, [isInView])

  const loadMoreTickets = async (offset: number) => {
    setIsLoading(true)
    setOffset((prev) => prev + 1)
    const { data: newTickets } = await fetchTickets(offset, PAGE_COUNT)
    setLoadedTickets((prevTickets) => [...prevTickets, ...newTickets])
    setIsLoading(false)
  }

  const fetchTickets = async (offset) => {
    const from = offset * PAGE_COUNT
    const to = from + PAGE_COUNT - 1

    const { data } = await supabase!
        .from('my_tickets_table')
        .select('*')
        .range(from, to)
        .order('createdAt', { ascending: false })

    return data
  }

  return (
    <div ref={containerRef}>
      {
        loadedTickets.map((ticket, index) => {
          const recalculatedDelay =
            i >= PAGE_COUNT * 2 ? (i - PAGE_COUNT * (offset - 1)) / 15 : i / 15

          return (
            <motion.div
              key={ticket.id}
              initial={{ opacity: 0, y: 20 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{
                duration: 0.4,
                ease: [0.25, 0.25, 0, 1],
                delay: recalculatedDelay,
              }}
            >
              {/* Actual ticket component */}
            </motion.div>
          )
        })
      }
    </div>
  )

export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
  const { data: tickets } = await supabase!
    .from('my_tickets_table')
    .select('*')
    .order('createdAt', { ascending: false })
    .limit(20)

  return {
    props: {
      tickets,
    },
  }
}

If you also want to feature in the endless tickets page and have a chance to win cool swag, you're invited to generate your unique ticket until April 16th.

See you at Launch Week! 👋

Share this article

Last post

Designing with AI: Generating unique artwork for every user

7 April 2023

Next post

SupaClub

1 April 2023

Related articles

Building ChatGPT Plugins with Supabase Edge Runtime

Flutter Hackathon

Supabase Beta April 2023

Securing your Flutter apps with Multi-Factor Authentication

Next steps for Postgres pluggable storage

Build in a weekend, scale to millions