Shoji

Infinite scroll

Shoji has no dedicated infinite-scroll plugin, and isn't going to grow one — its actual footprint in a scrolling gallery is small: one core method (updateSlides(), plus its addSlides()/removeSlides() sugar), one lookup convention for opening the right slide (data-shoji-id), and a plugin choice. Everything else — the sentinel/scroll-position logic that decides when to fetch more, how the on-page grid renders or virtualizes its tiles, any scroll-anchoring math — is plain host code Shoji never sees or participates in. The Infinite scroll example has that code in full; this guide is about the handful of places Shoji itself is actually touched.

updateSlides(): the only thing Shoji needs to know

Every time the host's own list of loaded items changes — a new page appended, an older page prepended, or nothing at all if a scroll tick only changed which tiles are currently rendered — call gallery.updateSlides(items) with the full, current, correctly-ordered array. That's the entire contract everything else in this guide is sugar over. Shoji diffs the new array against what it already knew by id (see the Methods guide), so it doesn't matter whether items were added at the start, the end, or both — there's no separate "append" vs. "prepend" call required.

One real bug this surfaces if missed: the bidirectional example keeps its loaded items in a Map, and Map iteration order is insertion order, not numeric order — loading an older page inserts smaller ids that still land last in that order. Handing Shoji that raw, unsorted iteration order would make next/prev inside the lightbox jump around instead of following the visual grid. Every updateSlides() call sorts first, exactly once, right before handing the array over.

gallery.items, addSlides()/removeSlides(): usually less to write than a raw updateSlides() call

Most hosts don't actually need "there's no separate append vs. prepend API" above — they only ever append. gallery.addSlides(items, atIndex?) is sugar over updateSlides() for exactly that: it splices items into a copy of whatever Shoji already has (default: append at the end; Array.prototype.splice semantics for atIndex, so 0 prepends) and hands the result off — still doing the same active-slide preservation updateSlides() always does. gallery.removeSlides(match) is the reverse, for eviction: a single id/index, or an array mixing both. Both read from gallery.items (a public, read-only getter — the exact array Shoji itself is holding right now, already resolved), so a host that has no other reason to track its own parallel copy of the list often doesn't need to: the forward-only demo keeps no host-side item array at all anymore, reading gallery.items wherever it needs the current list instead. See the Methods guide for the full signatures.

This isn't always a win, though — the bidirectional demo deliberately keeps calling updateSlides() directly, not addSlides()/removeSlides(). It already needs its own id → item map for its tile pool's own O(1) lookups (assigning/releasing a pooled DOM element by item id as the viewport scrolls), so there's no state saved by also asking Shoji to track a second copy piecemeal — one full, already-sorted array per load (from a map that's capped small regardless, see below) is simpler to reason about than working out which of two Shoji calls a given load needs. Reach for addSlides()/removeSlides() when gallery.items can just be your list; keep calling updateSlides() directly when you already have a good reason (like this one) to maintain your own.

Opening a slide: data-shoji-id, not a captured index

A thumbnail's click handler shouldn't close over the index it happened to render at — by the time it's clicked, updateSlides() may have grown or reordered the array underneath it. Both demos instead store the item's id on the element (a.dataset.shojiId = item.id) and re-resolve the index fresh, at click time, from whatever the current array actually is — gallery.items for forward-only (it keeps no array of its own), the sorted Map for bidirectional (it already has one, for other reasons — see above):

function openById(id) {
  const index = gallery.items.findIndex((i) => i.id === id);
  if (index !== -1) gallery.open(index);
}

This is also why Shoji's own origin lookup (Gallery.getOriginElement()) checks for a data-shoji-id-marked element first, before falling back to a selector-mode scan — dynamic mode has no scanned DOM to fall back to at all, so the marker is the only way Shoji can find the on-page thumbnail a given slide opened from (for the zoom-from-thumbnail transition, and anywhere else it needs that element).

Plugin choice: ActiveThumbnail only where nothing gets unmounted

Forward-only enables it — tiles are only ever appended, never removed, so there's no way the plugin ends up tracking an element that's since vanished. Bidirectional deliberately leaves it off: its on-page grid is virtualized (see the example), meaning the exact tile ActiveThumbnail would be highlighting can be unmounted the moment it scrolls outside the rendered window — a real interaction worth avoiding by not combining them, rather than adding recovery logic for it.

If you build a virtualized grid like the bidirectional example's, it only stays compatible with Shoji's Layout plugin in grid mode (uniform tile sizes) — not masonry or justified, which size rows from each photo's real aspect ratio, so there's no fixed row height for a virtual list's position math to rely on.
The forward-only demo, by contrast, never virtualizes what it appends — no windowing, no loading="lazy", tiles stay mounted forever. That's a fine trade for a demo, or a gallery bounded to some thousands of photos in a session; a genuinely unbounded forward scroll would eventually need the same kind of windowing the bidirectional demo does, just for one direction instead of two. Shoji itself doesn't care either way — updateSlides()/addSlides() only touch whatever array you hand them, so nothing about switching a forward-only integration to a windowed one changes how it talks to Shoji.

See the Infinite scroll example for the actual scroll-position and DOM-virtualization code — the sentinel/IntersectionObserver pattern for forward-only, and the fixed-row virtual list for bidirectional. None of it is Shoji-specific; it's ordinary host code that happens to call updateSlides() and open() at the right moments.