Drupalwoo ☉

Implementing Infinite Scroll In Drupal With Views And JavaScript

Infinite scroll can make a Drupal listing feel faster by loading the next group of results as visitors approach the end of the current content. It suits article archives, event directories, property listings, product catalogues, and community notices where users commonly browse several pages without needing to select a pager link.

The feature should enhance a normal Views listing rather than replace it. A reliable implementation keeps pagination available for keyboard users, search engines, slow connections, and visitors who have JavaScript disabled. The Drupal side controls filtering, access checks, sorting, and rendered output, while JavaScript manages when the next page is requested and where it is inserted.

Choose A View And Define The Contract

Create a View containing the content type or entity collection that visitors need to browse. Add the fields or rendered entity display required for each result, then apply explicit filters, access checks, and a deterministic sort order. A publication date combined with the node ID is safer than a date alone because two records may have identical timestamps.

Set a sensible pager, such as 12 or 20 items per page. The exact number depends on the design and the weight of each result. A directory with small text rows can load more records than a card grid containing responsive images. Keep the pager enabled even when the visible pager will eventually be replaced by JavaScript.

Decide what the feed returns before writing the browser code. A useful response contains the rendered result markup, the current page, and a flag indicating whether another page exists:

{
  "items": [
    { "html": "<article>...</article>" }
  ],
  "has_more": true,
  "page": 0
}

This contract keeps the JavaScript independent of the internal Views query. It also allows the Drupal implementation to change from a REST export to a custom controller without changing the loading behaviour.

Build A Stable Feed Endpoint

There are two practical Drupal approaches. A Views REST export can expose the results as JSON, provided the output fields are predictable and the endpoint has the correct cache and access configuration. This works well when the front end is responsible for assembling each card from simple values such as a title, URL, image URL, and summary.

A custom route or controller is preferable when the result markup already exists in Twig. The controller can execute the View for a requested page, render each row with Drupal’s theme system, and return the resulting HTML with has_more. Server-rendered markup preserves field formatters, language handling, link attributes, image styles, and editorial display rules.

The endpoint must validate the page parameter and use the same filters as the initial View. Do not accept arbitrary sort fields or database conditions from the query string. Drupal access checks still apply to every entity. If an unpublished node becomes inaccessible between requests, it must not appear in the returned fragment.

For a REST export, configure the View path as something such as /articles-feed and make sure the pager query is honoured. Drupal commonly uses zero-based page values, meaning page=0 is the first page and page=1 is the second. Keep that convention consistent in both the initial request and the JavaScript state. Add JSON cache metadata where appropriate, but avoid caching personalised or permission-sensitive results for anonymous and authenticated users together.

Add The Drupal Behaviour

Attach a small library to the page containing the listing. A library definition might look like this:

infinite_scroll:
  js:
    js/infinite-scroll.js: {}
  dependencies:
    - core/drupal
    - core/once

The markup needs a container, a loading message, a sentinel near the bottom, and a usable fallback control. The endpoint can be supplied through a data attribute, which avoids hard-coding a site-specific path in the JavaScript:

<div class="js-infinite-list"
     data-feed-url="/articles-feed">
  <div class="js-infinite-items">
    <!-- Initial View rows -->
  </div>

  <div class="js-infinite-sentinel" aria-hidden="true"></div>
  <p class="js-infinite-status" role="status" aria-live="polite"></p>
  <button class="js-infinite-more" type="button">
    Load more
  </button>
</div>

Drupal behaviours are essential because content can be attached more than once, particularly after an AJAX form submission or a partial page replacement. The once() utility prevents multiple observers from being registered on the same listing. Store the page number, loading state, and completion state inside each listing so two independent Views on one page do not interfere with one another.

A response can be rendered on the server and appended as a trusted fragment:

(function (Drupal, once) {
  Drupal.behaviors.infiniteScroll = {
    attach(context) {
      once('infinite-scroll', '.js-infinite-list', context).forEach((list) => {
        const items = list.querySelector('.js-infinite-items');
        const sentinel = list.querySelector('.js-infinite-sentinel');
        const status = list.querySelector('.js-infinite-status');
        const button = list.querySelector('.js-infinite-more');
        const url = list.dataset.feedUrl;

        let page = 1;
        let loading = false;
        let finished = false;

        const loadNext = async () => {
          if (loading || finished) return;

          loading = true;
          button.disabled = true;
          status.textContent = 'Loading more results…';

          try {
            const requestUrl = new URL(url, window.location.origin);
            requestUrl.searchParams.set('page', page);

            const response = await fetch(requestUrl, {
              headers: { Accept: 'application/json' }
            });

            if (!response.ok) {
              throw new Error(`Request failed: ${response.status}`);
            }

            const data = await response.json();
            data.items.forEach((item) => {
              items.insertAdjacentHTML('beforeend', item.html);
            });

            page += 1;
            finished = !data.has_more;
            button.hidden = finished;
            status.textContent = finished
              ? 'All results are displayed.'
              : '';
          }
          catch (error) {
            status.textContent =
              'More results could not be loaded. Use the button to try again.';
            button.hidden = false;
            console.error(error);
          }
          finally {
            loading = false;
            button.disabled = false;
          }
        };

        button.addEventListener('click', loadNext);

        const observer = new IntersectionObserver((entries) => {
          if (entries.some((entry) => entry.isIntersecting)) {
            loadNext();
          }
        }, { rootMargin: '600px 0px' });

        observer.observe(sentinel);
      });
    }
  };
})(Drupal, once);

If the project uses ES modules, import Drupal and once through the project’s normal Drupal library build process instead of relying on global variables. The important principles remain the same: one behaviour attachment, one request at a time, and a clear response contract.

Trigger Loading Without Losing Control

IntersectionObserver is preferable to a scroll event because the browser can monitor the sentinel efficiently without running calculations on every scroll tick. A bottom margin of several hundred pixels starts the request before the visitor reaches the end, allowing the next page to arrive in time on a normal connection.

The loading guard prevents duplicate requests when the observer fires repeatedly. The finished flag stops requests after the final page. Keep the fallback button active even when automatic loading is enabled. It helps visitors using keyboard navigation, assistive technology, browser extensions that block observers, or a connection that temporarily fails.

Do not assume that a successful HTTP response means that useful results were returned. Handle an empty items array, malformed JSON, rate-limit responses, and server errors. A failed request should leave the current results in place and expose a retry action rather than silently trapping the visitor at the bottom of the page.

When a filter form or exposed Views filter changes, reset the page state and clear the old items before loading the first filtered response. Also disconnect the old observer if the listing is replaced. Without this reset, a visitor may receive page three from the previous filter combined with page one from the new filter.

Preserve Accessibility And Navigation

Infinite scroll can create a poor experience for people who use a keyboard or screen reader if focus moves unexpectedly. Appended content should remain after the existing content, and focus should not jump to the first new card. The live status region can announce that more results were loaded without reading every new item aloud.

A visible “Load more” button is a useful accessibility and usability fallback. It should be reachable in the normal tab order, have a clear disabled state while a request is pending, and remain available when automatic loading fails. Do not hide the button with CSS alone before JavaScript has initialised; otherwise a failed script removes the only way to reach later results.

Make the initial View page a valid, shareable URL. A conventional pager or a ?page=2 link can be progressively enhanced into the automatic interface. Search engines and users opening a copied link should still receive useful content without depending on an observer or a previous client-side request.

For long archives, consider updating the URL with history.replaceState() as the visitor moves through pages, but do so carefully. A restored URL should represent a meaningful server-rendered state, not a page that exists only in the browser’s memory. Avoid manipulating the browser history for every small request unless back-button behaviour has been tested thoroughly.

Tune Performance For Australian Audiences

The initial View and subsequent responses should be small enough for mobile visitors on congested networks. Australia has strong urban connectivity through NBN and 4G or 5G services, but regional and remote visitors can experience higher latency, limited data, or less consistent coverage. Loading one modest page ahead is safer than requesting a large archive or preloading several pages.

Use responsive image styles, lazy loading, and fixed dimensions for thumbnails so appended cards do not cause layout shifts. If the View returns images, ensure the image style is appropriate for the displayed breakpoint. Defer non-essential scripts and avoid re-running expensive layout calculations after each insertion.

Drupal’s render cache, dynamic page cache, and HTTP caching can make repeated anonymous requests inexpensive, but cache contexts must match the listing. Language, URL filters, permissions, taxonomy selections, and query parameters may all change the result. A feed filtered for Melbourne events must never be served as the cached response for a Brisbane request.

The same considerations matter for an Australian marketplace or directory where visitors may browse from Sydney, Melbourne, Perth, or smaller regional centres. Keep labels and date formatting consistent with the site’s locale, and configure the View’s timezone deliberately when displaying event times. A server configured in UTC should not make a local event appear to start on the wrong calendar day.

Test, Measure, And Maintain

Test the View with JavaScript disabled first. The normal pager should work, links should remain usable, and the initial page should contain complete content. Then test automatic loading with keyboard-only navigation, screen readers, browser zoom, reduced-motion preferences, slow network throttling, and a failed endpoint response.

Check duplicate records carefully. They usually indicate that the initial View and the feed use different filters, the page index is off by one, or two observer callbacks were registered. Also test a result set whose total count is exactly one page, exactly two pages, and one item beyond a complete page. These cases expose incorrect has_more calculations.

Use browser performance tools and Drupal logging to measure request duration, response size, server cache hits, and JavaScript errors. A feed that feels smooth on a developer workstation may still be slow for a visitor on a regional mobile connection. Monitor the number of database queries generated by the View, especially when relationships, entity reference fields, or complex exposed filters are enabled.

Finally, document the endpoint, page-index convention, cache contexts, and response schema beside the module or theme code. Drupal updates, redesigned card templates, and changes to the View can otherwise break the client silently. A small automated test that verifies the JSON contract and a browser test that loads two pages will protect the feature as the site evolves.