Shoji

Infinite scroll

Shoji has no dedicated infinite-scroll plugin — its entire integration surface for both demos below is updateSlides() (or its addSlides()/removeSlides() sugar), a data-shoji-id marker on each thumbnail, and a plugin choice. Everything else on this page — fetching, rendering, scroll position, virtualization — is plain host code Shoji never sees. See the Infinite scroll guide for what those touchpoints do and why.

Each new page is handed to gallery.addSlides() — sugar over updateSlides() for the append case, so there's no host-side item array to keep in sync with it (gallery.items is Shoji's own copy). ActiveThumbnail is enabled here — tiles are only ever appended, never removed, so there's no risk of it tracking a thumbnail that's since disappeared.

This page never virtualizes the DOM it appends to — every tile ever loaded stays mounted, with no loading="lazy" either. Fine for a demo, or a real gallery bounded to some thousands of photos in a session, but a genuinely unbounded scroll would eventually need the same windowing the bidirectional demo already does, just for one direction instead of two.

Loading…

Loading first page…

Code: the Shoji integration

Forward-only — enable the plugin, then addSlides() (sugar over updateSlides() for the append case, see the guide) each new page. No host-side item array to keep in sync — gallery.items is already Shoji's own copy:

const gallery = new Shoji(grid, { items: [], plugins: [Shoji.ActiveThumbnail] });

async function loadNextPage() {
  const newItems = await fetchNextPage();
  gallery.addSlides(newItems); // appends by default
  for (const item of newItems) grid.appendChild(renderThumb(item)); // sets a.dataset.shojiId
}

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

Bidirectional calls the plain updateSlides() instead of addSlides()/removeSlides() — it already needs its own id → item map for the tile pool's own lookups, so there's no state to save by also asking Shoji to track a second copy piecemeal; one full, already-sorted array per load is simpler than reasoning about which of two Shoji calls a given load needs. Sourced from that map, sorted before every call — Map iteration order is insertion order, not numeric order, and an older page's ids are always smaller but still inserted last:

function sortedItems() {
  return [...itemsById.values()].sort((a, b) => idNum(a.id) - idNum(b.id));
}
gallery.updateSlides(sortedItems()); // called after every load, in both directions

Click-to-open, identical in both demos — resolved fresh from the id, never a captured index:

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

Everything else on this page — the sentinel/IntersectionObserver pattern, the fixed-row virtual list, the scroll-position math — is in the script above, and covered conceptually in the guide. None of it touches Shoji.