From 75353657a33fa12cb3db7985ea2ba11423870865 Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Wed, 29 Apr 2026 08:03:17 +0100 Subject: [PATCH] feat(infinite-scroll): add IntersectionObserver utility new @zag-js/infinite-scroll package. exports createInfiniteScroll(options) and getSentinelProps(). pairs with async-list for fetching and virtualizer for large lists. examples for both, plus a basic case. --- examples/next-ts/package.json | 1 + .../next-ts/pages/infinite-scroll/basic.tsx | 79 +++++++++++ .../pages/infinite-scroll/with-async-list.tsx | 72 ++++++++++ .../infinite-scroll/with-virtualizer.tsx | 118 ++++++++++++++++ packages/utilities/infinite-scroll/README.md | 22 +++ .../utilities/infinite-scroll/package.json | 40 ++++++ .../utilities/infinite-scroll/src/index.ts | 128 ++++++++++++++++++ .../utilities/infinite-scroll/tsconfig.json | 7 + pnpm-lock.yaml | 13 ++ shared/src/routes.ts | 9 ++ 10 files changed, 489 insertions(+) create mode 100644 examples/next-ts/pages/infinite-scroll/basic.tsx create mode 100644 examples/next-ts/pages/infinite-scroll/with-async-list.tsx create mode 100644 examples/next-ts/pages/infinite-scroll/with-virtualizer.tsx create mode 100644 packages/utilities/infinite-scroll/README.md create mode 100644 packages/utilities/infinite-scroll/package.json create mode 100644 packages/utilities/infinite-scroll/src/index.ts create mode 100644 packages/utilities/infinite-scroll/tsconfig.json diff --git a/examples/next-ts/package.json b/examples/next-ts/package.json index 61d81ebc7e..683cfc22f5 100644 --- a/examples/next-ts/package.json +++ b/examples/next-ts/package.json @@ -52,6 +52,7 @@ "@zag-js/hover-card": "workspace:*", "@zag-js/i18n-utils": "workspace:*", "@zag-js/image-cropper": "workspace:*", + "@zag-js/infinite-scroll": "workspace:*", "@zag-js/interact-outside": "workspace:*", "@zag-js/json-tree-utils": "workspace:*", "@zag-js/gridlist": "workspace:*", diff --git a/examples/next-ts/pages/infinite-scroll/basic.tsx b/examples/next-ts/pages/infinite-scroll/basic.tsx new file mode 100644 index 0000000000..d721844133 --- /dev/null +++ b/examples/next-ts/pages/infinite-scroll/basic.tsx @@ -0,0 +1,79 @@ +import { createInfiniteScroll, getSentinelProps } from "@zag-js/infinite-scroll" +import { useEffect, useRef, useState } from "react" + +const PAGE_SIZE = 20 +const TOTAL = 200 + +const loadPage = (page: number): Promise => + new Promise((resolve) => { + setTimeout(() => { + const start = page * PAGE_SIZE + resolve(Array.from({ length: PAGE_SIZE }, (_, i) => `Item ${start + i + 1}`)) + }, 600) + }) + +export default function Page() { + const [items, setItems] = useState([]) + const [page, setPage] = useState(0) + const [loading, setLoading] = useState(false) + + const sentinelRef = useRef(null) + const scrollerRef = useRef(null) + + const hasMore = items.length < TOTAL + + useEffect(() => { + if (!sentinelRef.current || !scrollerRef.current) return + return createInfiniteScroll({ + sentinelEl: sentinelRef.current, + scrollerEl: scrollerRef.current, + hasMore, + loading, + threshold: "100px", + onLoadMore: async () => { + setLoading(true) + const next = await loadPage(page) + setItems((prev) => [...prev, ...next]) + setPage((p) => p + 1) + setLoading(false) + }, + }) + }, [hasMore, loading, page]) + + return ( +
+

Infinite Scroll — Basic

+

+ Loaded {items.length} / {TOTAL} +

+
+
    + {items.map((label, i) => ( +
  • + {label} +
  • + ))} +
  • +
+
+ {loading && "Loading…"} + {!loading && !hasMore && "End of list"} +
+
+
+ ) +} diff --git a/examples/next-ts/pages/infinite-scroll/with-async-list.tsx b/examples/next-ts/pages/infinite-scroll/with-async-list.tsx new file mode 100644 index 0000000000..f7204aa6c0 --- /dev/null +++ b/examples/next-ts/pages/infinite-scroll/with-async-list.tsx @@ -0,0 +1,72 @@ +import * as asyncList from "@zag-js/async-list" +import { createInfiniteScroll, getSentinelProps } from "@zag-js/infinite-scroll" +import { useMachine } from "@zag-js/react" +import { useEffect, useRef } from "react" + +interface Person { + name: string + url: string +} + +export default function Page() { + const service = useMachine(asyncList.machine as asyncList.Machine, { + async load({ signal, cursor }) { + if (cursor) cursor = cursor.replace(/^http:\/\//i, "https://") + const res = await fetch(cursor || "https://swapi.py4e.com/api/people/", { signal }) + const json = await res.json() + return { items: json.results, cursor: json.next } + }, + }) + + const api = asyncList.connect(service) + + const sentinelRef = useRef(null) + const scrollerRef = useRef(null) + + useEffect(() => { + if (!sentinelRef.current || !scrollerRef.current) return + return createInfiniteScroll({ + sentinelEl: sentinelRef.current, + scrollerEl: scrollerRef.current, + hasMore: api.hasMore, + loading: api.isLoading, + threshold: "200px", + onLoadMore: () => api.loadMore(), + }) + }, [api.hasMore, api.isLoading]) + + return ( +
+

Infinite Scroll — Async List

+

Loaded {api.items.length}

+
+
    + {api.items.map((item) => ( +
  • + {item.name} +
  • + ))} +
  • +
+
+ {api.isLoading && "Loading…"} + {!api.isLoading && !api.hasMore && "End of list"} +
+
+
+ ) +} diff --git a/examples/next-ts/pages/infinite-scroll/with-virtualizer.tsx b/examples/next-ts/pages/infinite-scroll/with-virtualizer.tsx new file mode 100644 index 0000000000..7e741085aa --- /dev/null +++ b/examples/next-ts/pages/infinite-scroll/with-virtualizer.tsx @@ -0,0 +1,118 @@ +import { createInfiniteScroll } from "@zag-js/infinite-scroll" +import { ListVirtualizer } from "@zag-js/virtualizer" +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react" + +const PAGE_SIZE = 50 +const TOTAL = 5000 + +const loadPage = (page: number): Promise => + new Promise((resolve) => { + setTimeout(() => { + const start = page * PAGE_SIZE + resolve(Array.from({ length: PAGE_SIZE }, (_, i) => `Row ${start + i + 1}`)) + }, 500) + }) + +export default function Page() { + const [items, setItems] = useState([]) + const [page, setPage] = useState(0) + const [loading, setLoading] = useState(false) + + const hasMore = items.length < TOTAL + + // Add a placeholder slot at the end while there's more to load. + // The virtualizer renders that slot as our sentinel. + const count = hasMore ? items.length + 1 : items.length + + const virtualizer = useMemo( + () => + new ListVirtualizer({ + count, + estimatedSize: () => 44, + overscan: 5, + }), + [count], + ) + + useSyncExternalStore(virtualizer.subscribe, virtualizer.getSnapshot, () => 0) + + const scrollRef = useRef(null) + + const setScrollRef = useCallback( + (el: HTMLDivElement | null) => { + scrollRef.current = el + if (el) virtualizer.init(el) + }, + [virtualizer], + ) + + useEffect(() => () => virtualizer.destroy(), [virtualizer]) + + const virtualItems = virtualizer.getVirtualItems() + const totalSize = virtualizer.getTotalSize() + + useEffect(() => { + const lastItem = virtualItems.at(-1) + if (!lastItem || !scrollRef.current) return + + // The sentinel is the last virtual row when more data is available. + const sentinelEl = scrollRef.current.querySelector(`[data-index="${lastItem.index}"]`) + if (!sentinelEl) return + + return createInfiniteScroll({ + sentinelEl, + scrollerEl: scrollRef.current, + hasMore, + loading, + onLoadMore: async () => { + setLoading(true) + const next = await loadPage(page) + setItems((prev) => [...prev, ...next]) + setPage((p) => p + 1) + setLoading(false) + }, + }) + }, [virtualItems, hasMore, loading, page]) + + return ( +
+

Infinite Scroll — Virtualized

+

+ Loaded {items.length} / {TOTAL} +

+
+
+ {virtualItems.map((virtualItem) => { + const isSentinelRow = hasMore && virtualItem.index === items.length + const style = virtualizer.getItemStyle(virtualItem) + return ( +
+ {isSentinelRow ? (loading ? "Loading…" : "") : items[virtualItem.index]} +
+ ) + })} +
+
+
+ ) +} diff --git a/packages/utilities/infinite-scroll/README.md b/packages/utilities/infinite-scroll/README.md new file mode 100644 index 0000000000..b20dbc9ab5 --- /dev/null +++ b/packages/utilities/infinite-scroll/README.md @@ -0,0 +1,22 @@ +# @zag-js/infinite-scroll + +Framework agnostic infinite scroll utility based on `IntersectionObserver`. + +## Installation + +```sh +yarn add @zag-js/infinite-scroll +# or +npm i @zag-js/infinite-scroll +``` + +## Contribution + +Yes please! See the +[contributing guidelines](https://github.com/chakra-ui/zag/blob/main/CONTRIBUTING.md) +for details. + +## Licence + +This project is licensed under the terms of the +[MIT license](https://github.com/chakra-ui/zag/blob/main/LICENSE). diff --git a/packages/utilities/infinite-scroll/package.json b/packages/utilities/infinite-scroll/package.json new file mode 100644 index 0000000000..ac9ad81599 --- /dev/null +++ b/packages/utilities/infinite-scroll/package.json @@ -0,0 +1,40 @@ +{ + "name": "@zag-js/infinite-scroll", + "version": "0.0.0", + "description": "Framework agnostic infinite scroll utility based on IntersectionObserver", + "keywords": [ + "js", + "utils", + "infinite-scroll", + "intersection-observer" + ], + "author": "Segun Adebayo ", + "homepage": "https://github.com/chakra-ui/zag#readme", + "license": "MIT", + "main": "src/index.ts", + "repository": "https://github.com/chakra-ui/zag/tree/main/packages/utilities/infinite-scroll", + "sideEffects": false, + "files": [ + "dist/**/*" + ], + "scripts": { + "build": "tsup", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "publishConfig": { + "access": "public" + }, + "bugs": { + "url": "https://github.com/chakra-ui/zag/issues" + }, + "clean-package": "../../../clean-package.config.json", + "dependencies": { + "@zag-js/dom-query": "workspace:*" + }, + "devDependencies": { + "clean-package": "2.2.0" + } +} diff --git a/packages/utilities/infinite-scroll/src/index.ts b/packages/utilities/infinite-scroll/src/index.ts new file mode 100644 index 0000000000..565ca3aa35 --- /dev/null +++ b/packages/utilities/infinite-scroll/src/index.ts @@ -0,0 +1,128 @@ +import { getNearestScrollableAncestor, getWindow } from "@zag-js/dom-query" + +export interface InfiniteScrollOptions { + /** + * The sentinel element to observe (placed at the end of the list). + */ + sentinelEl: HTMLElement | (() => HTMLElement | null) + /** + * The scrollable container. If not provided, the nearest scroll parent of + * the sentinel is used. + */ + scrollerEl?: HTMLElement | (() => HTMLElement | null) + /** + * Called when the sentinel enters the viewport. + */ + onLoadMore: () => void + /** + * Whether more data is available. When false, observer disconnects. + * @default true + */ + hasMore?: boolean + /** + * Whether a load is currently in progress. Prevents duplicate calls. + * @default false + */ + loading?: boolean + /** + * How far before the sentinel is visible to trigger loading. + * - `number` is interpreted as pixels (e.g. `200` → `"200px"`). + * - `string` accepts any valid CSS margin token, including percentages + * relative to the root (e.g. `"50%"`, `"200px"`, `"10% 0px"`). + * @default "200px" + */ + threshold?: number | string + /** + * Whether loading is paused (e.g., on error). Set to true to stop, + * false to resume. + * @default false + */ + disabled?: boolean +} + +type ElementOrGetter = HTMLElement | (() => HTMLElement | null) | undefined + +const resolveEl = (el: ElementOrGetter): HTMLElement | null => { + if (!el) return null + return typeof el === "function" ? el() : el +} + +const formatRootMargin = (threshold: number | string): string => { + const value = typeof threshold === "number" ? `${threshold}px` : threshold + return `${value} ${value} ${value} ${value}` +} + +const noop = () => {} + +export function createInfiniteScroll(options: InfiniteScrollOptions): () => void { + const { + sentinelEl, + scrollerEl, + onLoadMore, + hasMore = true, + loading = false, + threshold = "200px", + disabled = false, + } = options + + const sentinel = resolveEl(sentinelEl) + if (!sentinel) return noop + if (!hasMore || disabled) return noop + + const win = getWindow(sentinel) + if (typeof win.IntersectionObserver === "undefined") return noop + + // null root = page viewport. Falling back to the nearest scrollable + // ancestor matches the spec; if none exists, IO defaults to the viewport. + const root = resolveEl(scrollerEl) ?? getNearestScrollableAncestor(sentinel) + + const observer = new win.IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting && !loading && hasMore && !disabled) { + onLoadMore() + break + } + } + }, + { + root, + rootMargin: formatRootMargin(threshold), + }, + ) + + observer.observe(sentinel) + + return () => observer.disconnect() +} + +export interface SentinelProps { + "aria-hidden": true + inert: boolean + style: { + width: string + height: string + flexShrink: number + pointerEvents: "none" + } +} + +/** + * Returns props for the sentinel element. + * + * The sentinel must have non-zero size for `IntersectionObserver` to fire + * reliably across browsers. It is hidden from assistive tech and the + * focus order, and cannot intercept pointer events. + */ +export function getSentinelProps(): SentinelProps { + return { + "aria-hidden": true, + inert: true, + style: { + width: "1px", + height: "1px", + flexShrink: 0, + pointerEvents: "none", + }, + } +} diff --git a/packages/utilities/infinite-scroll/tsconfig.json b/packages/utilities/infinite-scroll/tsconfig.json new file mode 100644 index 0000000000..8e781cd154 --- /dev/null +++ b/packages/utilities/infinite-scroll/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src"], + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/.tsbuildinfo" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c1dea91e6..fddb985720 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,6 +251,9 @@ importers: '@zag-js/image-cropper': specifier: workspace:* version: link:../../packages/machines/image-cropper + '@zag-js/infinite-scroll': + specifier: workspace:* + version: link:../../packages/utilities/infinite-scroll '@zag-js/interact-outside': specifier: workspace:* version: link:../../packages/utilities/interact-outside @@ -3640,6 +3643,16 @@ importers: specifier: ^3.0.5 version: 3.0.5 + packages/utilities/infinite-scroll: + dependencies: + '@zag-js/dom-query': + specifier: workspace:* + version: link:../dom-query + devDependencies: + clean-package: + specifier: 2.2.0 + version: 2.2.0 + packages/utilities/interact-outside: dependencies: '@zag-js/dom-query': diff --git a/shared/src/routes.ts b/shared/src/routes.ts index 1556eb1c72..fe36bf118e 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -566,6 +566,15 @@ export const componentRoutes: ComponentRoute[] = [ { slug: "key-recorder", title: "Key Recorder" }, ], }, + { + slug: "infinite-scroll", + label: "Infinite Scroll", + examples: [ + { slug: "basic", title: "Basic" }, + { slug: "with-async-list", title: "With Async List" }, + { slug: "with-virtualizer", title: "With Virtualizer" }, + ], + }, { slug: "virtualizer", label: "Virtualizer",