Skip to content
djust/docs
Appearance
Mode
djust.org →
Browse documentation

6 min read

Tutorial: Build infinite scroll with dj-viewport-bottom

Pagination is dead in modern web UI. Users expect to scroll until they care to stop, not page-click through a 1990s table footer. The implementation has historically been a pain — a JS scroll listener, IntersectionObserver bookkeeping, race conditions when two scroll events fire before the first response lands, and remembering to dedupe when the user scrolls fast.

djust ships dj-viewport-bottom as a built-in primitive: it's a template attribute that fires a server event the moment its element scrolls into view. The infrastructure (IntersectionObserver setup, debouncing, cleanup) is the framework's problem.

By the end of this tutorial you'll have an activity feed where:

  • The first 20 entries render on initial load.
  • Scrolling near the bottom auto-loads the next 20 — triggered by an IntersectionObserver on a sentinel row, not a scroll listener.
  • A small inline "Loading more…" spinner appears during the fetch.
  • When you reach the end, the spinner is replaced by a quiet "That's all." marker — no perpetual loading state.
  • No JavaScript is authored — the dj-viewport-bottom attribute does the work.
You'll learnDocumented in
dj-viewport-bottom for sentinel-fired server eventsLarge Lists
Cursor-based pagination on the server sideThis tutorial
dj-for per-row diffing for append-only updatesLists (dj-for)
When to use dj-virtual instead of infinite scrollThis tutorial

Prerequisites: Quickstart, the search-as-you-type tutorial (sets up the loading-state vocabulary). A queryset with enough rows to actually scroll past — a few hundred is enough.


What you're building

┌──────────────────────────────────────┐
│ Activity                             │
├──────────────────────────────────────┤
│ alice pushed v1.4.2                  │
│ bob filed issue #1283                │
│ carla commented on #1281             │
│ ... 17 more rows ...                 │
│ alice merged PR #1280                │
├──────────────────────────────────────┤
│        Loading more …           │  ← appears as user scrolls
├──────────────────────────────────────┤
│ ... next 20 rows fade in here ...    │
└──────────────────────────────────────┘

The "Loading more…" sentinel is the trigger — it's a single row at the bottom of the list with dj-viewport-bottom="load_more". When it scrolls into view, the server fires load_more, which appends 20 more rows to the state list and re-renders. The sentinel remains at the new bottom, ready for the next trigger.


Step 1 — The model + initial query

# myapp/models.py
from django.db import models


class Activity(models.Model):
    actor = models.CharField(max_length=80)
    verb = models.CharField(max_length=200)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-id"]  # cursor-friendly: id is monotonic

We use id-based ordering because it's cursor-friendly. Avoid created_at cursors when there's any chance of ties at millisecond precision (CSV import, fixture loads).


Step 2 — The view, with a cursor + page size

# myapp/views.py
from djust import LiveView, state, event_handler

from .models import Activity


PAGE_SIZE = 20


class ActivityFeedView(LiveView):
    template_name = "feed.html"

    activities = state(default_factory=list)
    cursor = state(0)        # max id of the rows we've loaded
    has_more = state(True)
    loading = state(False)

    def mount(self, request, **kwargs):
        first_page = self._fetch(after_id=None)
        self.activities = first_page
        self.cursor = first_page[-1]["id"] if first_page else 0
        self.has_more = len(first_page) == PAGE_SIZE

    @event_handler
    def load_more(self, **kwargs):
        if self.loading or not self.has_more:
            return
        self.loading = True
        # Re-render once with the spinner shown — the next render
        # will replace it with the new rows + reset loading=False.
        page = self._fetch(after_id=self.cursor)
        if page:
            self.activities = self.activities + page
            self.cursor = page[-1]["id"]
        self.has_more = len(page) == PAGE_SIZE
        self.loading = False

    def _fetch(self, after_id: int | None) -> list[dict]:
        qs = Activity.objects.all()
        if after_id is not None:
            qs = qs.filter(id__lt=after_id)  # older rows
        rows = qs[:PAGE_SIZE]
        return [
            {"id": r.id, "actor": r.actor, "verb": r.verb,
             "created_at": r.created_at.isoformat()}
            for r in rows
        ]

Three patterns to call out:

  1. The cursor is the smallest id we've seen. Each fetch is id < cursor — so concurrent inserts at the head don't shift the page boundaries. With offset-based pagination, an insert at the top would shift the next page by one and you'd see a row twice. With cursor-based, never.
  2. Idempotent load_more. The early return if self.loading or not self.has_more makes it safe to fire repeatedly — even if the user scrolls faster than the server responds.
  3. has_more is a derived flag based on whether the last fetch returned a full page. Cleaner than counting against a total — the framework re-renders when it flips, so the sentinel disappears automatically when there's nothing left to load.

Step 3 — The template

<!-- myapp/templates/feed.html -->
<section class="feed">
  <h1>Activity</h1>

  <ul class="feed-list">
    {% dj-for entry in activities %}
      <li class="feed-item">
        <strong>{{ entry.actor }}</strong>
        <span>{{ entry.verb }}</span>
        <time datetime="{{ entry.created_at }}">{{ entry.created_at }}</time>
      </li>
    {% end-dj-for %}
  </ul>

  {% if has_more %}
    <div class="feed-sentinel"
         dj-viewport-bottom="load_more"
         aria-live="polite">
      {% if loading %}
        <span class="spinner" aria-hidden="true"></span>
        Loading more&hellip;
      {% else %}
        &nbsp;
      {% endif %}
    </div>
  {% else %}
    <p class="feed-end" role="status">That&rsquo;s all.</p>
  {% endif %}
</section>

The sentinel is the whole trick:

AttributeBehavior
dj-viewport-bottom="load_more"Fire the load_more server event when this element first crosses into the viewport. Backed by IntersectionObserver — no scroll listener, no polling. Re-arms after each render, so as long as the sentinel keeps appearing at the new bottom, it keeps working.
{% if has_more %}{% else %}When has_more flips false, the sentinel is replaced by "That's all." The IntersectionObserver detaches automatically (the framework cleans up).
aria-live="polite" on the sentinelScreen readers announce "Loading more…" when the spinner appears, then the new rows when they're rendered.

Why the sentinel and not dj-viewport-bottom on the last <li>? Either works, but a separate sentinel is simpler: the trigger element doesn't move (it's always the last DOM node) and the spinner UX has a natural place to live.


Step 4 — A bit of CSS

.feed-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
.feed-item {
  display: flex;
  align-items: baseline;
  gap: 12px;
  padding: 10px 16px;
  border-bottom: 1px solid var(--color-border, #e5e7eb);
  /* Subtle fade-in for newly-appended rows. The framework's per-row
     diff means only NEW rows get this animation — existing rows
     don't restyle on each load_more cycle. */
  animation: feed-row-in 0.2s ease-out;
}
@keyframes feed-row-in {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0); }
}
.feed-sentinel {
  display: flex;
  align-items: center;
  gap: 8px;
  justify-content: center;
  padding: 16px;
  font-size: 13px;
  color: var(--color-muted, #6b7280);
  min-height: 40px;
}
.feed-end {
  text-align: center;
  padding: 24px 16px;
  color: var(--color-muted, #6b7280);
  font-style: italic;
}
.spinner {
  width: 14px;
  height: 14px;
  border: 2px solid var(--color-border, #e5e7eb);
  border-top-color: var(--color-accent, #3b82f6);
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

The animation: feed-row-in on .feed-item is the only place the per-row diff matters. Because the framework only patches NEW rows (not existing ones) on each load_more, only the new rows trigger the animation. Existing rows stay still.


Step 5 — Wire the URL and try it

# myapp/urls.py
from django.urls import path
from .views import ActivityFeedView

urlpatterns = [path("activity/", ActivityFeedView.as_view())]

Visit /activity/ and scroll. The sentinel triggers a load_more event when it crosses into view; the spinner shows for the duration of the round-trip; then the next 20 rows fade in below the existing ones. Repeat until you've loaded everything, at which point the sentinel becomes "That's all."


When to use dj-virtual instead

Infinite scroll is the right pattern for append-only lists where the user reads top-to-bottom and the dataset is unbounded — feeds, chats, logs, activity streams.

For finite, browsable datasets where the user might jump around (a 100K-row admin table they want to scroll, search, sort), reach for dj-virtual instead. It windows the DOM so only the visible slice renders — O(visible) DOM cost regardless of dataset size — and supports the full set of rows without needing to load them in pages.

Quick decision rule:

  • Append-only + linear reading → infinite scroll (dj-viewport-bottom + cursor)
  • Browse-anywhere + finite total → virtual scroll (dj-virtual)

You can combine them — virtual scroll over a windowed slice that itself loads more on bottom — but that's an advanced setup; start with one or the other.


What just happened, end to end

   Browser                                 Server
      │                                       │
      │ initial load                          │
      │ ────────────────────────────────────► mount()
      │                                       │  fetch first 20
      │ ◄ render: 20 rows + sentinel ────────│  cursor = id of #20
      │                                       │
      │ user scrolls...                       │
      │ sentinel crosses viewport             │
      │ ────────────────────────────────────► load_more
      │                                       │  loading = True
      │ ◄ patch: spinner inside sentinel ─────│
      │                                       │  fetch next 20 (id<cursor)
      │                                       │  activities += page
      │                                       │  cursor = id of new last
      │                                       │  loading = False
      │ ◄ patch: new <li> rows + spinner ─────│
      │   gone (replaced by &nbsp;)           │
      │                                       │
      │ ... eventually fetch returns < 20 ... │
      │                                       │  has_more = False
      │ ◄ patch: sentinel replaced by ────────│
      │   "That's all." marker                │

The IntersectionObserver detaches when the sentinel is removed from the DOM. No leaked listeners, no polling.


Where to go next

  • Bidirectional scroll (loading older rows when scrolling up): pair dj-viewport-bottom with dj-viewport-top on a sentinel above the first row, with a load_older handler. Use scrollIntoView() after prepending to keep the user's read position stable.
  • Real-time inserts at the head: combine with the real-time comments tutorial's @notify_on_save pattern. New rows from other users get prepended; the user's scroll position stays locked unless they were at the very top.
  • DOM cap for chat / log viewers: large feeds left running for hours bloat the DOM. Use a stream limit=N to prune from the opposite edge automatically — the visual effect is "scroll up forever, but the DOM never exceeds N rows."
  • Save-and-restore scroll position across navigations: the framework's Sticky LiveViews keep the view mounted (and the scroll position intact) when the user navigates away and comes back.

The four-line shape — sentinel + dj-viewport-bottom + idempotent handler + cursor — is the entire infinite-scroll API. The hard parts (observer setup, race conditions, cleanup) are off your plate. Drag it onto a search results page, a notification list, a log tail — same recipe.

Spotted a typo or want to improve this page? Edit on GitHub →