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.
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).
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, noloading="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.