npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@phipri/react-threadport

v1.2.0

Published

Headless virtualized chat viewport primitives for React.

Readme

Threadport

Headless virtualized chat viewport primitives for React, built on TanStack Virtual. Threadport is intended to mimic the scroll behavior of ChatGPT and Claude-style applications while your app owns messages, composer, buttons, styling, and layout.

Demo

  • Site: https://threadport.pprice.me/
  • Basic example: https://threadport.pprice.me/examples/basic
  • Full featured example: https://threadport.pprice.me/examples/full-featured
  • Insets example: https://threadport.pprice.me/examples/insets
  • Long response example: https://threadport.pprice.me/examples/long-response

Install

npm install @phipri/react-threadport react

Usage

import * as ThreadPort from '@phipri/react-threadport'
import { useRef } from 'react'

type Message = { id: string; body: string }

export function Chat({ messages }: { messages: Message[] }) {
  const viewportRef = useRef<ThreadPort.ViewportHandle | null>(null)

  return (
    <ThreadPort.Root>
      <ThreadPort.Viewport
        ref={viewportRef}
        items={messages}
        getItemKey={(message) => message.id}
        estimateSize={() => 160}
        renderItem={({ item }) => <article>{item.body}</article>}
        headInset={64}
        tailInset={168}
        initialAnchor="tail"
        className="chatScroll"
        scrollElementProps={{ 'data-gesture-root': 'chat' }}
        scrollAnimation={ThreadPort.Animation.easeOutCubic(420)}
        tailReserve
        virtualizerOptions={{ overscan: 12 }}
      />

      <ThreadPort.Overlay placement="tail">
        <Composer
          onSubmit={(messageId) => {
            viewportRef.current?.scrollToItem(messageId, {
              align: 'head',
              animation: ThreadPort.Animation.easeOutQuart(520),
            })
          }}
        />
      </ThreadPort.Overlay>
    </ThreadPort.Root>
  )
}

Concepts

  • head: older/start side of the transcript.
  • tail: newer/end side of the transcript.
  • inset: persistent overlap from app chrome, such as a header or composer.
  • reserve: intentional space, such as unloaded history or active tail space.
  • threshold: tolerance for isAtHead and isAtTail.

API

Viewport props:

| Prop | Type | Required | Description | | --- | --- | --- | --- | | items | readonly TItem[] | Yes | Items to virtualize. | | getItemKey | (item, index) => string \| number | Yes | Stable key for each item. | | estimateSize | (item, index) => number | Yes | Estimated row height before measurement. | | renderItem | (args) => ReactNode | Yes | Renders one item. | | ariaLabel | string | No | Accessible label for the scroll region. | | atHeadThreshold | number | No | Distance in px considered "at head". | | atTailThreshold | number | No | Distance in px considered "at tail". | | className | string | No | Class for the scroll element. | | contentClassName | string | No | Class for the virtual content element. | | headInset | number | No | Persistent overlap at the head, usually top chrome. | | headReserve | number | No | Extra reserved space before the first item. | | initialAnchor | 'head' \| 'tail' | No | Initial scroll position. | | itemClassName | string | No | Class for each measured virtual row. | | itemGap | number | No | Gap in px between rows. | | onStateChange | (state) => void | No | Receives scroll distances, booleans, sizes, and render count. | | overscan | number | No | Extra rows rendered outside the viewport. | | preserveScrollOnPrepend | boolean | No | Keeps the visible anchor stable when items are inserted at the head. | | role | string | No | ARIA role for the scroll element. | | scrollAnimation | ScrollAnimation | No | Default animation for imperative scroll commands. | | scrollElementProps | ScrollElementProps | No | Extra props for the scroll element, such as data-*, id, and title. Use className for classes. | | style | CSSProperties | No | Inline style for the scroll element. | | tailInset | number | No | Persistent overlap at the tail, usually composer space. | | tailReserve | boolean \| TailReserveOptions | No | Gives the active appended tail item a viewport-sized minimum height. | | virtualizerOptions | VirtualizerOptions | No | Safe TanStack Virtual options; Threadport-owned scroll/padding options are omitted. |

virtualizerOptions is for TanStack tuning without prop thunking. Threadport still owns count, getScrollElement, item keys, estimates, inset padding, scroll padding, onChange, orientation, lanes, and initial offset. overscan and itemGap are shorthands that win over virtualizerOptions.overscan and virtualizerOptions.gap.

Imperative handle:

  • scrollToHead(options)
  • scrollToTail(options)
  • scrollToIndex(index, { align, animation })
  • scrollToItem(key, { align, animation })
  • measure(), getState(), getScrollElement(), stopScrollAnimation()

Animation factories:

  • Animation.linear(duration)
  • Animation.easeOutQuad(duration), Animation.easeOutCubic(duration), Animation.easeOutQuart(duration), Animation.easeOutQuint(duration)
  • Animation.easeInOutCubic(duration)

Frame helpers:

  • Root: shares inset and scrollbar geometry with overlays.
  • Overlay: frame-relative overlay; avoids the scrollbar lane by default and can forward wheel events to the viewport.
  • useRootState: read frame geometry in custom UI.

Layout

<ThreadPort.Root> ships with display: flex; flex-direction: column; min-height: 0 as inline-style defaults. <ThreadPort.Viewport> ships with flex: 1 1 auto; min-height: 0; overflow-y: auto. Together: the viewport fills any parent that gives it a definite height, and stays out of the way of any parent that doesn't.

The contract: the Root's parent must have a definite height for internal scrolling. The library can't manufacture a height; it can only respect the one you provide.

Four common patterns:

// 1. 100dvh shell — the canonical "chat fills the screen" layout
<div style={{ height: '100dvh', display: 'flex', flexDirection: 'column' }}>
  <header>...</header>
  <ThreadPort.Root style={{ flex: 1 }}>...</ThreadPort.Root>
</div>

// 2. Fixed pixel container
<ThreadPort.Root style={{ height: 600 }}>...</ThreadPort.Root>

// 3. Sized grid track
<div style={{ display: 'grid', gridTemplateRows: 'minmax(0, 1fr)', height: '100dvh' }}>
  <ThreadPort.Root>...</ThreadPort.Root>
</div>

// 4. Variable, react-state driven
<div style={{ height: hostHeight }}>
  <ThreadPort.Root>...</ThreadPort.Root>
</div>

If the Root's parent is content-sized — no height, no flex: 1, not a stretching grid item with a constrained row — the viewport will grow with each new message instead of scrolling. That's the symptom of an unconstrained parent.

When stacking inside a flex column, min-height: 0 matters at every level: parent flex items default to min-height: auto (content-based) and won't shrink to allow overflow. Threadport sets it on its own elements; you may need it on yours too.

The headInset and tailInset props reserve space within the viewport for chrome (sticky headers, composers via Overlay). They are not a substitute for a sized parent.

Other rules of thumb:

  • Keep composer and floating controls outside Viewport. Use Overlay with placement="head" / placement="tail" for frame-relative chrome.
  • Pass overlap as headInset / tailInset; do not fake it with message padding.
  • Use tailReserve when newly appended responses should start with a screen of empty space beneath them.

Chat Transcript Patterns

The full featured example at https://threadport.pprice.me/examples/full-featured shows a staged assistant response with loading/search phases, streaming Markdown, feedback controls, a variable-height composer, and a jump-to-bottom affordance.

For mixed row types, keep one virtualized item stream and render by discriminated type:

type TranscriptItem =
  | { kind: 'message'; id: string; role: 'user' | 'assistant'; body: string }
  | { kind: 'tool'; id: string; label: string; state: 'running' | 'complete' }
  | { kind: 'image'; id: string; src: string; width: number; height: number }

<ThreadPort.Viewport
  items={items}
  getItemKey={(item) => item.id}
  estimateSize={(item) => estimateTranscriptRow(item)}
  renderItem={({ item }) => <TranscriptRow item={item} />}
/>

Use stable keys for every row, including tool/status/image rows. If the row scrolls with the transcript, make it an item; keep only fixed chrome such as the composer or jump button in Overlay.

For tail behavior during streaming, treat isAtTail as policy input rather than a command. Capture whether the user was following the tail before you append or extend the assistant row, then decide whether to follow the stream or show a jump control:

const stateRef = useRef<ThreadPort.ViewportState | null>(null)
const [showJump, setShowJump] = useState(false)

function appendAssistantDelta(delta: string) {
  const wasAtTail = stateRef.current?.isAtTail ?? true

  setItems((items) => applyAssistantDelta(items, delta))

  if (wasAtTail) {
    requestAnimationFrame(() => viewportRef.current?.scrollToTail({ duration: 0 }))
  } else {
    setShowJump(true)
  }
}

That policy is separate from "submitted prompt to head" behavior. Many chat apps scroll the new user message to the head, then follow live output only while the human stays near the tail.

For mobile Safari and dynamic Markdown heights:

  • Put app gesture hooks on the scroll element with scrollElementProps, and classes/styles through className / style.
  • Avoid nested touch scrollers around the viewport; let Threadport own the scroll container.
  • Give images, previews, and embeds stable dimensions or aspect ratios before content loads.
  • Let Markdown reflow normally. Threadport observes rendered row height changes and preserves the appropriate anchor after measurement.

Development

This package lives in a pnpm monorepo. From the repo root:

pnpm install
pnpm dev:site                  # marketing home + examples site
pnpm dev:harness               # Playwright fixtures host
pnpm test:react                # vitest unit tests
pnpm test:integration          # Playwright integration tests
pnpm build:lib                 # tsup build for the published package

React component tests live in packages/react-threadport/tests/react. Browser-backed integration tests live in the repo's tests/integration.

License

MIT