https://github.com/suhaotian/broad-infinite-list
A high-performance, bidirectional infinite list for React/Vue/Expo. Use case: smoothly chat history, news feed updates, or stream logs in both directions without layout shifts. Best for large scroll list.
https://github.com/suhaotian/broad-infinite-list
bidirectional-scrollview expo infinite-list infinite-loading react react-native vue3 vue3-typescript
Last synced: 4 months ago
JSON representation
A high-performance, bidirectional infinite list for React/Vue/Expo. Use case: smoothly chat history, news feed updates, or stream logs in both directions without layout shifts. Best for large scroll list.
- Host: GitHub
- URL: https://github.com/suhaotian/broad-infinite-list
- Owner: suhaotian
- License: mit
- Created: 2026-02-03T01:24:41.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-02-09T05:41:05.000Z (4 months ago)
- Last Synced: 2026-02-09T17:57:53.963Z (4 months ago)
- Topics: bidirectional-scrollview, expo, infinite-list, infinite-loading, react, react-native, vue3, vue3-typescript
- Language: TypeScript
- Homepage: https://suhaotian.github.io/broad-infinite-list
- Size: 938 KB
- Stars: 3
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
# [Broad Infinite List](https://suhaotian.github.io/broad-infinite-list/) Β· [](https://bundlejs.com/?q=broad-infinite-list/react&treeshake=%5B%7Bdefault%7D%5D&config=%7B%22esbuild%22:%7B%22external%22:%5B%22react%22,%22react-dom%22,%22react/jsx-runtime%22%5D%7D%7D) [](https://www.npmjs.com/package/broad-infinite-list) [](https://github.com/suhaotian/broad-infinite-list/pulls) [](https://github.com/suhaotian/broad-infinite-list/blob/main/LICENSE) [](https://www.jsdocs.io/package/broad-infinite-list) 
**broad-infinite-list** is a tiny component that renders large lists efficiently by showing only a limited range of items. No need to configure each rowβs height or use fixed row heights. It is suitable for chat message lists, news feed lists, and stream logs.
## Features
- π Bidirectional infinite scrolling
- β‘ High performance - only renders fixed items
- π Dynamic heights - no configuration needed
- πͺ Window or container scrolling
## Demos
- Chat Messages List [Demo](https://suhaotian.github.io/broad-infinite-list?demo=chat)
- News feed [Demo](https://suhaotian.github.io/broad-infinite-list?demo=news)
- Logs [Demo](https://suhaotian.github.io/broad-infinite-list?demo=logs)
- Vue Chat [Demo](https://suhaotian.github.io/broad-infinite-list/vue?demo=chat)
- ChatGPT [Demo](https://suhaotian.github.io/broad-infinite-list?demo=chatgpt)
- Claude theme [Demo](https://suhaotian.github.io/broad-infinite-list?demo=claude)
### Expo Demo Preview(ReactNative)
> [!NOTE]
> Scan it, then open it in Expo Go.

**How It Works**
Define a fixed window of visible items (e.g., 30 entries from a 100,000-record dataset). Load items entering the viewport as the user scrolls, and remove items leaving the viewport. This keeps rendered items constant and maintains smooth performance with large datasets.

## Installation
```bash
npm install broad-infinite-list
```
## Quick Start
> [!CAUTION]
> For vue3 or React Native usage, check the example in [`vue-example/src/App.vue`](./vue-example/src/App.vue) or [`rn-expo-example/app/(tabs)/index.tsx`](<./rn-expo-example/app/(tabs)/index.tsx>)
> [!NOTE]
> For React web, copy and paste the below demo code, then run it.
```tsx
"use client";
import { useState, useRef } from "react";
import BidirectionalList, {
type BidirectionalListProps,
type BidirectionalListRef,
} from "broad-infinite-list/react";
interface NewsItem {
id: number;
title: string;
category: string;
timestamp: number;
}
const CATEGORIES = ["Tech", "Science", "Politics", "Business"];
const TITLES = [
"Senate Passes Landmark Infrastructure Bill",
"New AI Model Achieves Human-Level Performance",
"Global Temperatures Record Highest Monthly Average",
"Startup Raises $200M Series C for Autonomous Systems",
];
const TOTAL = 1000000;
const ALL_NEWS: NewsItem[] = Array.from({ length: TOTAL }, (_, i) => ({
id: i + 1,
title: `${i + 1}. ${TITLES[i % TITLES.length] || ""}`,
category: CATEGORIES[i % CATEGORIES.length] || "",
timestamp: Date.now() - (TOTAL - i) * 3600000,
}));
const NEWS_BY_RECENCY = [...ALL_NEWS].reverse();
const VIEW_COUNT = 50;
const PAGE_SIZE = 20;
function MyList() {
const [items, setItems] = useState(
NEWS_BY_RECENCY.slice(0, VIEW_COUNT)
);
const newestLoaded = items[0]?.id ?? 0;
const oldestLoaded = items[items.length - 1]?.id ?? ALL_NEWS.length + 1;
const hasPrevious = newestLoaded < ALL_NEWS.length;
const hasNext = oldestLoaded > 1;
const handleLoadMore: BidirectionalListProps["onLoadMore"] = async (
direction,
refItem
) => {
await new Promise((r) => setTimeout(r, 200));
const idx = NEWS_BY_RECENCY.findIndex((n) => n.id === refItem.id);
if (direction === "down") {
return NEWS_BY_RECENCY.slice(idx + 1, idx + PAGE_SIZE + 1);
} else {
return NEWS_BY_RECENCY.slice(idx - PAGE_SIZE, idx);
}
};
const listRef = useRef(null);
const showScrollTopButton = items?.[0]?.id !== NEWS_BY_RECENCY[0]?.id;
const onScrollToFirst = () => {
setItems(NEWS_BY_RECENCY.slice(0, VIEW_COUNT));
listRef.current?.scrollToTop();
};
return (
<>
ref={listRef}
items={items}
itemKey={(item) => item.id.toString()}
spinnerRow={
}
renderItem={(item) => (
{item.title}
{item.category}
)}
onLoadMore={handleLoadMore}
onItemsChange={setItems}
hasPrevious={hasPrevious}
hasNext={hasNext}
viewCount={VIEW_COUNT}
useWindow={true}
/>
β
Total: {TOTAL}(1million)
Display: {items.length}
useWindow: {"true"}
>
);
}
const Spinner = () => (
);
export default MyList;
```
### BidirectionalList Props
| Property | Type | Required | Default | Description |
| ------------------ | --------------------------------------------------------- | -------- | ----------- | -------------------------------------------------------------------------------- |
| `items` | `T[]` | Yes | - | Current array of items to display |
| `itemKey` | `(item: T) => string` | Yes | - | Function to extract a unique key from each item |
| `renderItem` | `(item: T) => React.ReactNode` | Yes | - | Function to render each item |
| `onLoadMore` | `(direction: "up" \| "down", refItem: T) => Promise` | Yes | - | Called when more items should be loaded; returns the new items to prepend/append |
| `hasPrevious` | `boolean` | Yes | - | Whether there are more items available above the current view |
| `hasNext` | `boolean` | Yes | - | Whether there are more items available below the current view |
| `onItemsChange` | `(items: T[]) => void` | No | `undefined` | Called when the items array changes due to loading or trimming |
| `className` | `string` | No | `undefined` | The container tag's className |
| `itemClassName` | `string \| (items: T, index: number) => string` | No | `undefined` | The item tag's className |
| `itemStyle` | `itemStyle?: CSSProperties \| ((item: T, index: number) => CSSProperties \| undefined);` | No | `undefined` | The item element's style |
| `listClassName` | `string` | No | `undefined` | The list wrapper div's className |
| `containerAs` | `string` | No | `div` | The container tag, default is div, example: conatinerAs='table' |
| `as` | `string` | No | `div` | The list wrapper tag, default is div, example: as='ul' |
| `itemAs` | `string` | No | `div` | The item tag, default is div, example: itemAs='li' |
| `spinnerRow` | `React.ReactNode` | No | `undefined` | Custom loading indicator shown during fetch |
| `emptyState` | `React.ReactNode` | No | `undefined` | Content to display when items array is empty |
| `viewCount` | `number` | No | `50` | Maximum number of items to keep in DOM; older items are trimmed |
| `threshold` | `number` | No | `10` | Pixel distance from edge to trigger loading |
| `useWindow` | `boolean` | No | `false` | If true, use window scroll instead of container scroll |
| `disable` | `boolean` | No | `false` | If true, disable loading in both directions |
| `onScrollStart` | `() => void` | No | `undefined` | Called when a programmatic scroll adjustment begins |
| `onScrollEnd` | `() => void` | No | `undefined` | Called when a programmatic scroll adjustment ends |
| `headerSlot` | `({children}: {children: ReactNode}) => children` | No | `undefined` | for table element |
| `footerSlot` | `({children}: {children: ReactNode}) => children` | No | `undefined` | for table element |
### BidirectionalListRef
| Property | Type | Required | Default | Description |
| ------------------- | -------------------------------------------------- | -------- | ------- | --------------------------------------------- |
| `scrollViewRef` | `RefObject` | Yes | - | Reference to the scrollable container element |
| `scrollToTop` | `(behavior?: ScrollBehavior) => void` | Yes | - | Scroll to the top of the list |
| `scrollToBottom` | `(behavior?: ScrollBehavior) => void` | Yes | - | Scroll to the bottom of the list |
| `scrollTo` | `(top: number, behavior?: ScrollBehavior) => void` | Yes | - | Scroll to a specific pixel offset from top |
| `scrollToKey` | `(key: string, behavior?: ScrollBehavior) => void` | Yes | - | Scroll to an item by its key |
| `getTopDistance` | `() => number` | Yes | - | Get Current distnace to top |
| `getBottomDistance` | `() => number` | Yes | - | Get Current distnace to bottom |
## Development
This project use bun, but in `rn-expo-example/` use pnpm.
```sh
bun install && bun run build
```
## Reporting Issues
Found an issue? Please feel free to [create issue](https://github.com/suhaotian/broad-infinite-list/issues/new)
## Support
If you find this project helpful, consider [buying me a coffee](https://github.com/suhaotian/broad-infinite-list/stargazers).
## Projects You May Also Be Interested In
- [xior](https://github.com/suhaotian/xior) - Tiny fetch library with plugins support and axios-like API
- [tsdk](https://github.com/tsdk-monorepo/tsdk) - Type-safe API development CLI tool for TypeScript projects
- [useNextTick](https://github.com/suhaotian/use-next-tick) - `nextTick` but for react: Running callbacks after the DOM or native views have updated.