Making Infinite Scroll Work with Client-Side Filtering

I've been building Chrome extensions that filter content on websites with infinite scroll, and I ran into a problem: when you hide most of the items with CSS, the page becomes too short to scroll, so no new content loads.

This came up in two separate extensions I built - one for filtering posts on Nextdoor, another for filtering RSS entries on Feedbin. After some trial and error, I found an approach that seems to work reasonably well.

The Problem

Many websites use infinite scroll. As you scroll down, they automatically fetch and append more content. This typically works by detecting when you've scrolled near the bottom of the page.

When you add client-side filtering (hiding items based on tags, keywords, etc.), you run into a problem:

  1. You hide items with display: none
  2. Hidden items don't take up space
  3. The page becomes too short to scroll
  4. The infinite scroll loader never triggers
  5. No new content loads
  6. User is stuck with whatever items weren't filtered out on first load

This is pronounced when there is heavy filtering.

Failed Approach

My first attempt was to programmatically scroll to the bottom after filtering:

function scrollToLoadMore() {
  const container = document.querySelector('.entries')
  const lastEntry = container.querySelector('.entry:last-child')
  lastEntry.scrollIntoView({ behavior: 'instant', block: 'end' })
}

This didn't work reliably. If most entries are hidden, there might not be enough scroll height to trigger the loader at all. Scrolling to the "bottom" just snaps back to where you were.

The Solution

The best solution I've found is to create an invisible spacer element that maintains scroll height even when most content is hidden.

Here's some sample CSS:

/* Invisible spacer to ensure infinite scroll works when entries are filtered */
.scroll-spacer {
  height: 200vh;
  visibility: hidden;
  pointer-events: none;
}

/* Hide spacer when enough content is visible */
.scroll-spacer.has-content {
  display: none;
}

The spacer is 200% of viewport height, which ensures there's always room to scroll. It's invisible (visibility: hidden) so it doesn't affect the visual layout, and has no pointer events so it doesn't interfere with clicks.

Here's the JavaScript to manage it:

class FilteredList {
  constructor() {
    this.spacer = null
    this.MIN_VISIBLE_ITEMS = 15
    this.loadMoreAttempts = 0
    this.maxLoadMoreAttempts = 10
    this.lastItemCount = 0
  }

  createSpacer() {
    if (this.spacer) return this.spacer

    this.spacer = document.createElement('div')
    this.spacer.className = 'scroll-spacer'

    const container = document.querySelector('.entries')
    if (container) {
      container.appendChild(this.spacer)
    }

    return this.spacer
  }

  applyFilters() {
    const items = document.querySelectorAll('.entry')
    let visibleCount = 0

    items.forEach((item) => {
      if (this.shouldShow(item)) {
        item.style.display = ''
        visibleCount++
      } else {
        item.style.display = 'none'
      }
    })

    this.triggerLoadMoreIfNeeded(visibleCount)
  }

  triggerLoadMoreIfNeeded(visibleCount) {
    this.createSpacer()

    // Enough visible items - hide spacer
    if (visibleCount >= this.MIN_VISIBLE_ITEMS) {
      this.spacer.classList.add('has-content')
      this.loadMoreAttempts = 0
      return
    }

    // Show spacer to maintain scroll height
    this.spacer.classList.remove('has-content')

    // Check if new items loaded since last attempt
    const totalItems = document.querySelectorAll('.entry').length
    if (totalItems === this.lastItemCount && this.loadMoreAttempts > 0) {
      // No new items - source is exhausted
      return
    }

    // Limit attempts to avoid infinite loops
    if (this.loadMoreAttempts >= this.maxLoadMoreAttempts) {
      return
    }

    this.lastItemCount = totalItems
    this.loadMoreAttempts++

    // Scroll to trigger loading, then scroll back
    setTimeout(() => {
      const container = document.querySelector('.entries').parentElement
      container.scrollTo(0, container.scrollHeight)

      setTimeout(() => {
        container.scrollTo(0, 0)
      }, 300)
    }, 200)
  }
}

Key Details

Why 200vh? It needs to be tall enough that the page is always scrollable, regardless of how many items are hidden. 200% of viewport height guarantees this.

Why visibility: hidden instead of display: none? We want the spacer to take up space (maintaining scroll height) but not be visible. display: none would defeat the purpose.

Why scroll back up? Without this, the user's view would jump around. Scrolling to bottom triggers the loader, then we immediately scroll back so the user doesn't notice.

Detecting New Items

You'll also want a MutationObserver to detect when the page loads new items:

observeNewItems() {
  const container = document.querySelector('.entries')

  const observer = new MutationObserver(mutations => {
    let hasNewItems = false

    mutations.forEach(mutation => {
      mutation.addedNodes.forEach(node => {
        if (node.matches?.('.entry')) {
          hasNewItems = true
        }
      })
    })

    if (hasNewItems) {
      // Debounce to avoid excessive reapplication
      clearTimeout(this.filterTimeout)
      this.filterTimeout = setTimeout(() => {
        this.applyFilters()
      }, 100)
    }
  })

  observer.observe(container, { childList: true, subtree: true })
}

This creates a nice cycle:

  1. Apply filters → too few visible → show spacer → scroll
  2. Site loads more items → MutationObserver fires
  3. Apply filters again → check if enough visible now
  4. Repeat until enough items or source exhausted

Conclusion

The spacer pattern has worked well across two different extensions with different sites. This pattern should work for any site with infinite scroll where you want to do client-side filtering. The main things you need to customize are the selectors and possibly the scroll container detection.

Categories: main

« Vibe Coding 24/7 On A Screen For Ants