diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js
index 39cafa5f..d25d4d13 100644
--- a/static/js/app/filesView.js
+++ b/static/js/app/filesView.js
@@ -24,6 +24,7 @@ import { inlineViewer } from '../features/files/inlineViewer.js';
import { favorites } from '../features/library/favorites.js';
import { fetchResourcesPage, rebuildBreadCrumb } from '../model/filesModel.js';
import { grants } from '../model/grants.js';
+import { attachInfiniteScroll } from '../utils/infiniteScroll.js';
import { resolveHomeFolder } from './authSession.js';
import { updateHistory } from './main.js';
import { app } from './state.js';
@@ -289,6 +290,8 @@ function _ensureLoadMoreButton() {
wrapper.appendChild(btn);
filesContainer.after(wrapper);
+
+ attachInfiniteScroll(wrapper, () => _loadPage({ isFirstPage: false }));
}
/**
diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js
new file mode 100644
index 00000000..9cdc6337
--- /dev/null
+++ b/static/js/utils/infiniteScroll.js
@@ -0,0 +1,60 @@
+// @ts-check
+
+/**
+ * Infinite-scroll for cursor-paginated views.
+ *
+ * Each section that paginates (Files, Favorites, Recent, Trash,
+ * SharedWithMe, MyShares) injects a "Load more" button wrapped in a
+ * `
` below `.files-container`. The wrapper toggles `.hidden` to
+ * reflect whether a next page exists.
+ *
+ * This utility wires an [`IntersectionObserver`] onto the same wrapper
+ * so it auto-fires the load-more action when the user scrolls near it.
+ * The visible button stays as a fallback (accessibility, browsers
+ * without IntersectionObserver support, the user's reflex from
+ * before infinite-scroll landed).
+ *
+ * Wire it once per wrapper at creation time. The observer is idempotent
+ * across re-renders because the wrapper itself is re-used; appended
+ * items push it down the document and trigger fresh intersection
+ * events when the user scrolls again.
+ *
+ * Re-entrancy: every existing `_loadPage` implementation has its own
+ * in-flight guard (a `_loading` flag returning early on re-entry), so
+ * this utility doesn't add another. If a future view doesn't, add a
+ * `_loading` field on the view object before adopting infinite-scroll.
+ */
+
+/**
+ * Auto-fire `onLoadMore()` when `wrapper` scrolls within
+ * `rootMargin` of the viewport AND is visible (the `.hidden` class is
+ * the cursor-exhausted signal — see `_setLoadMoreVisible` in each view).
+ *
+ * @param {HTMLElement} wrapper The `.swm-load-more-wrapper` element.
+ * @param {() => void} onLoadMore Called on each intersection-enter.
+ * @param {{rootMargin?: string}} [opts]
+ * @returns {() => void} Teardown — disconnects the observer.
+ */
+export function attachInfiniteScroll(wrapper, onLoadMore, opts = {}) {
+ if (!('IntersectionObserver' in window)) {
+ // Older browser — degrade to the button-only experience.
+ return () => {};
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (!entry.isIntersecting) continue;
+ // No next page → wrapper hidden → skip.
+ if (wrapper.classList.contains('hidden')) continue;
+ onLoadMore();
+ }
+ },
+ // Trigger a bit before the wrapper actually enters the viewport
+ // so the next page is on its way by the time the user reaches
+ // the bottom — smoother than a perceptible "click… wait…" beat.
+ { rootMargin: opts.rootMargin ?? '200px' }
+ );
+ observer.observe(wrapper);
+ return () => observer.disconnect();
+}
diff --git a/static/js/views/favorites/favoritesView.js b/static/js/views/favorites/favoritesView.js
index a9bee282..79d82b0a 100644
--- a/static/js/views/favorites/favoritesView.js
+++ b/static/js/views/favorites/favoritesView.js
@@ -27,6 +27,7 @@ import * as itemTooltip from '../../features/itemTooltip.js';
import { favorites } from '../../features/library/favorites.js';
import { fetchFavoritesPage } from '../../model/favoritesModel.js';
import { systemUsers } from '../../model/systemUsers.js';
+import { attachInfiniteScroll } from '../../utils/infiniteScroll.js';
/** @import {FavoritesResourceItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */
@@ -429,6 +430,8 @@ const favoritesView = {
wrapper.appendChild(btn);
filesContainer.after(wrapper);
+
+ attachInfiniteScroll(wrapper, () => this._loadPage());
},
/**
diff --git a/static/js/views/myShares/mySharesView.js b/static/js/views/myShares/mySharesView.js
index 37089c15..7bd1f0de 100644
--- a/static/js/views/myShares/mySharesView.js
+++ b/static/js/views/myShares/mySharesView.js
@@ -19,6 +19,7 @@ import * as viewPrefs from '../../core/viewPrefs.js';
import * as itemTooltip from '../../features/itemTooltip.js';
import { grants } from '../../model/grants.js';
import { groups } from '../../model/groups.js';
+import { attachInfiniteScroll } from '../../utils/infiniteScroll.js';
/** @import {FileItem, FolderItem} from '../../core/types.js' */
@@ -256,6 +257,8 @@ const mySharesView = {
wrapper.appendChild(btn);
filesContainer.after(wrapper);
+
+ attachInfiniteScroll(wrapper, () => this._loadPage());
},
/** @param {boolean} visible */
diff --git a/static/js/views/recent/recentView.js b/static/js/views/recent/recentView.js
index b68a8ca2..44fa0446 100644
--- a/static/js/views/recent/recentView.js
+++ b/static/js/views/recent/recentView.js
@@ -28,6 +28,7 @@ import * as itemTooltip from '../../features/itemTooltip.js';
import { favorites } from '../../features/library/favorites.js';
import { fetchRecentPage } from '../../model/recentModel.js';
import { systemUsers } from '../../model/systemUsers.js';
+import { attachInfiniteScroll } from '../../utils/infiniteScroll.js';
/** @import {FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */
@@ -436,6 +437,8 @@ const recentView = {
wrapper.appendChild(btn);
filesContainer.after(wrapper);
+
+ attachInfiniteScroll(wrapper, () => this._loadPage());
},
/**
diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js
index 8fd2d030..6bdacdcc 100644
--- a/static/js/views/sharedWithMe/sharedWithMeView.js
+++ b/static/js/views/sharedWithMe/sharedWithMeView.js
@@ -21,6 +21,7 @@ import * as itemTooltip from '../../features/itemTooltip.js';
import { favorites } from '../../features/library/favorites.js';
import { grants } from '../../model/grants.js';
import { systemUsers } from '../../model/systemUsers.js';
+import { attachInfiniteScroll } from '../../utils/infiniteScroll.js';
/** @import {SharedWithMeItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */
@@ -438,6 +439,8 @@ const sharedWithMeView = {
wrapper.appendChild(btn);
filesContainer.after(wrapper);
+
+ attachInfiniteScroll(wrapper, () => this._loadPage());
},
/**
diff --git a/static/js/views/trash/trashView.js b/static/js/views/trash/trashView.js
index a4b0f79a..476843b3 100644
--- a/static/js/views/trash/trashView.js
+++ b/static/js/views/trash/trashView.js
@@ -24,6 +24,7 @@ import * as viewPrefs from '../../core/viewPrefs.js';
import { fileOps } from '../../features/files/fileOperations.js';
import * as itemTooltip from '../../features/itemTooltip.js';
import { fetchTrashPage } from '../../model/trashModel.js';
+import { attachInfiniteScroll } from '../../utils/infiniteScroll.js';
/** @import {FileItem, FolderItem, ResourceTypeEnum, TrashResourceItem} from '../../core/types.js' */
@@ -449,6 +450,8 @@ const trashView = {
wrapper.appendChild(btn);
filesContainer.after(wrapper);
+
+ attachInfiniteScroll(wrapper, () => this._loadPage());
},
/**