{"_id":"@ancora/react","name":"@ancora/react","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@ancora/react","version":"0.1.0","description":"Pixel-stable virtualized message list for AI chat and streaming logs. Bottom-pin holds under token streaming; reading mode never shifts. The MIT alternative to VirtuosoMessageList.","license":"MIT","author":{"name":"Asish Samanta"},"type":"module","main":"./dist/index.cjs","module":"./dist/index.mjs","types":"./dist/index.d.ts","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.mjs"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}}},"sideEffects":false,"scripts":{"build":"tsup","dev":"tsup --watch","typecheck":"tsc --noEmit","prepublishOnly":"npm run build"},"keywords":["react","virtualization","virtual-list","chat","streaming","ai","ai-sdk","usechat","scroll","stick-to-bottom","pin","message-list","tanstack"],"repository":{"type":"git","url":"git+https://github.com/asish-dev/ancora.git","directory":"packages/react"},"homepage":"https://github.com/asish-dev/ancora/tree/main/packages/react#readme","bugs":{"url":"https://github.com/asish-dev/ancora/issues"},"publishConfig":{"access":"public"},"engines":{"node":">=18"},"dependencies":{"@ancora/core":"^0.1.0"},"peerDependencies":{"@tanstack/react-virtual":"~3.14.5","react":">=18 <20","react-dom":">=18 <20"},"devDependencies":{"@tanstack/react-virtual":"3.14.5","@types/react":"^19.0.0","react":"^19.0.0","tsup":"^8.3.0","typescript":"^5.7.0"},"_id":"@ancora/react@0.1.0","gitHead":"3bda6ae3a443681aea66bc6b5e719825d8717aef","_nodeVersion":"22.23.1","_npmVersion":"10.9.8","dist":{"integrity":"sha512-G7qclmo8HIbQSfzF8+CLtjDNglIBB9871ymbSeNbXOu0UGo8hpreSs2RduxiM07uOFnpfugazNWIxJ+pofd7bg==","shasum":"1b22a7e1a1b3a5205e7376f58e0d3f9caea6a1db","tarball":"https://registry.npmjs.org/@ancora/react/-/react-0.1.0.tgz","fileCount":9,"unpackedSize":112589,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIGHvXWXNRH5V8R6YlrJ3Vw6pKXMIseFvXs2D9BGOy9eGAiEA/PVl3RAn8A04DB25CKsrKX43fCgmtnrwfsY0SNNDcps="}]},"_npmUser":{"name":"chad-dev","email":"asish043@gmail.com"},"directories":{},"maintainers":[{"name":"chad-dev","email":"asish043@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/react_0.1.0_1786558169544_0.09270979402448809"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-12T18:09:28.847Z","0.1.0":"2026-08-12T18:09:29.763Z","modified":"2026-08-12T18:09:30.107Z"},"maintainers":[{"name":"chad-dev","email":"asish043@gmail.com"}],"description":"Pixel-stable virtualized message list for AI chat and streaming logs. Bottom-pin holds under token streaming; reading mode never shifts. The MIT alternative to VirtuosoMessageList.","homepage":"https://github.com/asish-dev/ancora/tree/main/packages/react#readme","keywords":["react","virtualization","virtual-list","chat","streaming","ai","ai-sdk","usechat","scroll","stick-to-bottom","pin","message-list","tanstack"],"repository":{"type":"git","url":"git+https://github.com/asish-dev/ancora.git","directory":"packages/react"},"author":{"name":"Asish Samanta"},"bugs":{"url":"https://github.com/asish-dev/ancora/issues"},"license":"MIT","readme":"# @ancora/react\n\n**Pixel-stable virtualized message list for AI chat and streaming logs.** The\nMIT alternative to react-virtuoso's commercial `VirtuosoMessageList`.\n\nTwo guarantees, enforced by a cross-engine CI gate (Chromium, WebKit, Firefox):\n\n1. **The bottom pin never wobbles while tokens stream.** The last message can\n   grow every frame; the list bottom stays glued to the viewport bottom —\n   0 jitter frames.\n2. **Reading mode is sacred.** Once you scroll up, nothing on screen moves — not\n   for prepended history, not for streaming below the fold, not for late size\n   measurement — 0px shift.\n\nIt virtualizes only when it needs to: short conversations render as normal DOM\n(find-in-page, text selection, and screen readers just work), and switch to\nwindowing above a threshold, invisibly.\n\n```bash\nnpm install @ancora/react @tanstack/react-virtual\n```\n\n`react`, `react-dom` (18 or 19) and `@tanstack/react-virtual` are peer\ndependencies. TanStack Virtual is an internal engine — you never import or\nconfigure it; it is a peer only so a single copy is shared with the rest of\nyour app.\n\n## Quick start\n\n```tsx\nimport { ChatList } from \"@ancora/react\";\n\ntype Msg = { id: string; role: \"user\" | \"assistant\"; text: string };\n\nexport function Chat({ messages }: { messages: Msg[] }) {\n  return (\n    <ChatList<Msg>\n      messages={messages}\n      getKey={(m) => m.id}\n      renderMessage={(m) => (\n        <div className={`bubble ${m.role}`}>{m.text}</div>\n      )}\n      style={{ height: \"100%\" }}\n    />\n  );\n}\n```\n\nThat's the whole integration. Append a message and the pin follows it; push\ntokens onto the last message and the bottom stays put. `<ChatList>` owns the\nscroll container, so give it a bounded height (via `style`/`className`) and let\nit fill that space.\n\n> **The one rule: keys must be stable.** `getKey` must return the same string\n> for the same logical message on every render. Ancora anchors the viewport by\n> key; index-based or content-based keys will break pin and scroll restoration.\n> In development it warns when it detects unstable keys.\n\n## How it decides to virtualize\n\n| Messages | Mode | Behavior |\n| --- | --- | --- |\n| ≤ `virtualizeThreshold` (default 100) | **Flow** | Plain DOM. Native find-in-page, selection, a11y. |\n| > threshold | **Windowed** | Only visible rows are mounted. |\n\nThe switch happens only at safe moments (while pinned, or deferred until you\nscroll back to the bottom), so you never see it. Behavior is identical in both\nmodes — the mode is an implementation detail, not something your UI reacts to.\n\n## Streaming with the AI SDK (`useChat`)\n\nAncora takes a plain array, so `useChat` needs no glue:\n\n```tsx\nimport { useChat } from \"@ai-sdk/react\";\nimport { ChatList } from \"@ancora/react\";\n\nexport function Assistant() {\n  const { messages } = useChat();\n  return (\n    <ChatList\n      messages={messages}\n      getKey={(m) => m.id}\n      renderMessage={(m) => <Message message={m} />}\n      style={{ height: \"100dvh\" }}\n    />\n  );\n}\n```\n\nEvery streamed token gives `messages` a new identity; ancora diffs it, sees the\ntail grew, and holds the pin — no refs, no scroll callbacks.\n\n## Loading older history (infinite scroll up)\n\n`onReachTop` fires once when a user scroll enters the top zone. Prepend older\nmessages and the anchor the reader is looking at stays fixed to the pixel:\n\n```tsx\n<ChatList\n  messages={messages}\n  getKey={(m) => m.id}\n  renderMessage={renderMessage}\n  onReachTop={() => loadOlder()}     // prepend to `messages`\n  reachTopThreshold={400}            // px from top that arms it (default 200)\n/>\n```\n\nIt re-arms only after history actually arrives, so a single scroll can't fire\nit twice.\n\n## A \"scroll to latest\" button\n\nAncora ships no UI. Build the chip yourself from the pin state:\n\n```tsx\nimport { useRef, useState } from \"react\";\nimport { ChatList, type ChatListHandle } from \"@ancora/react\";\n\nfunction Chat({ messages }) {\n  const list = useRef<ChatListHandle>(null);\n  const [pinned, setPinned] = useState(true);\n  return (\n    <div style={{ position: \"relative\", height: \"100%\" }}>\n      <ChatList\n        handleRef={list}\n        messages={messages}\n        getKey={(m) => m.id}\n        renderMessage={renderMessage}\n        onPinChange={setPinned}\n        style={{ height: \"100%\" }}\n      />\n      {!pinned && (\n        <button onClick={() => list.current?.scrollToBottom()}>\n          ↓ Latest\n        </button>\n      )}\n    </div>\n  );\n}\n```\n\n## Optimistic sends (temp id → server id)\n\nWhen you send a message optimistically with a temporary id and later swap in the\nserver id, tell ancora the two ids are the same message via `keyAliases`\n(`newKey → previousKey`). It treats the change as an identity swap instead of a\nremove-and-append, so pin and anchor survive:\n\n```tsx\n<ChatList\n  messages={messages}\n  getKey={(m) => m.id}\n  keyAliases={new Map([[serverId, tempId]])}\n  renderMessage={renderMessage}\n/>\n```\n\nWithout an alias, ancora still behaves correctly but warns in development that a\nswap looked like a delete + insert.\n\n## Props\n\n| Prop | Type | Default | |\n| --- | --- | --- | --- |\n| `messages` | `readonly M[]` | — | Your message array (any shape). |\n| `getKey` | `(m, i) => string` | — | **Stable** unique id per message. |\n| `renderMessage` | `(m, i) => ReactNode` | — | Renders one message. |\n| `onPinChange` | `(pinned: boolean) => void` | — | Fires on pin/unpin transitions. |\n| `onReachTop` | `() => void` | — | Fires when a user scroll reaches the top zone. |\n| `virtualizeThreshold` | `number` | `100` | Message count above which windowing turns on. |\n| `estimateSize` | `(m, i) => number` | `100` | Row height estimate (windowed mode) before measurement. Rough is fine; a per-type estimate reduces first-paint correction. |\n| `keyAliases` | `ReadonlyMap<string,string>` | — | `newKey → previousKey` identity equivalences. |\n| `repinThreshold` | `number` | `2` | px from bottom within which scrolling repins. |\n| `reachTopThreshold` | `number` | `200` | px from top that arms `onReachTop`. |\n| `handleRef` | `Ref<ChatListHandle>` | — | Imperative handle (below). |\n| `className` / `style` | | | Applied to the scroll container. |\n\n### Imperative handle\n\n```ts\ninterface ChatListHandle {\n  readonly pinned: boolean;              // current pin state\n  scrollToBottom(): void;                // scroll to bottom and pin\n  scrollToMessage(key: string): Promise<boolean>; // align a message to the top;\n                                         // resolves true when within 1px\n}\n```\n\n## Styling\n\nAncora styles nothing except the layout it must own (the scroll container's\noverflow and scroll anchoring). Your `renderMessage` output is entirely yours.\nGive the component a bounded height; internal rows are laid out by the engine —\ndon't add margins that collapse between rows in windowed mode (use padding\ninside your bubble instead).\n\n## Browser support\n\n| Environment | Status |\n| --- | --- |\n| Chrome / Chromium, Safari / WebKit, Firefox (desktop) | ✅ certified — CI gate: 0 jitter frames, 0px anchor shift |\n| iOS Safari | ⚠️ not yet certified (momentum + rubber-banding differ from desktop WebKit) — targeted for a follow-up release |\n\n## License\n\nMIT © Asish Samanta\n","readmeFilename":"README.md","_rev":"1-d250bf0f5749a2651cbda876a73a4010"}