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

chat-scroll-behavior

v0.1.2

Published

Framework-agnostic chat scroll behavior for loading older and newer messages.

Readme

chat-scroll-behavior

Framework-agnostic chat scroll behavior for loading older and newer messages.

This package does not render UI. Your app owns messages, markup, fetching, styling, and framework state. The package owns the scroll logic that modern chat surfaces need:

  • Load older messages near the top.
  • Preserve the viewport when older messages are prepended.
  • Load newer messages near the bottom.
  • Auto-scroll to the bottom only when the user was already near the bottom.
  • Share the same behavior across vanilla JavaScript, React, Next.js, and other frameworks.

Install

npm install chat-scroll-behavior

Mental Model

The package calls onLoadTop or onLoadBottom when the scroll container reaches a threshold. Those callbacks return message records. Your app merges those records in onMergeTop or onMergeBottom, usually with mergeMessages.

When prepending older messages, the controller measures before your merge, waits for the next frame, then adjusts scrollTop by the added height so the viewport stays anchored.

Each edge triggers once while the user remains inside its threshold. The edge is armed again after the user scrolls away and then returns, which prevents package-driven scroll restoration or auto-scroll from starting an accidental load loop.

Vanilla JavaScript

import {
	createChatScrollController,
	mergeMessages,
} from "chat-scroll-behavior";

const container = document.querySelector("#messages");
let messages = [];

function render() {
	container.innerHTML = messages
		.map((message) => `<div>${message.text}</div>`)
		.join("");
}

const controller = createChatScrollController({
	container,
	getMessageId: (message) => message.id,
	onLoadTop: () =>
		fetch("/api/messages?before=oldest").then((res) => res.json()),
	onMergeTop: (incoming) => {
		messages = mergeMessages(messages, incoming, {
			getMessageId: (message) => message.id,
			direction: "prepend",
		});
		render();
	},
	onLoadBottom: () =>
		fetch("/api/messages?after=latest").then((res) => res.json()),
	onMergeBottom: (incoming) => {
		messages = mergeMessages(messages, incoming, {
			getMessageId: (message) => message.id,
			direction: "append",
		});
		render();
	},
});

controller.check();

React

import { useRef, useState } from "react";
import { mergeMessages } from "chat-scroll-behavior";
import { useChatScroll } from "chat-scroll-behavior/react";

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

export function Chat() {
	const containerRef = useRef<HTMLDivElement | null>(null);
	const [messages, setMessages] = useState<Message[]>([]);

	const chatScroll = useChatScroll<Message>({
		containerRef,
		getMessageId: (message) => message.id,
		onLoadTop: async () => fetchOlderMessages(),
		onMergeTop: (incoming) => {
			setMessages((current) =>
				mergeMessages(current, incoming, {
					getMessageId: (message) => message.id,
					direction: "prepend",
				}),
			);
		},
		onLoadBottom: async () => fetchNewerMessages(),
		onMergeBottom: (incoming) => {
			setMessages((current) =>
				mergeMessages(current, incoming, {
					getMessageId: (message) => message.id,
					direction: "append",
				}),
			);
		},
	});

	return (
		<>
			<div ref={containerRef} style={{ height: 480, overflowY: "auto" }}>
				{messages.map((message) => (
					<article key={message.id}>{message.text}</article>
				))}
			</div>
			<button type="button" onClick={() => chatScroll.scrollToBottom()}>
				Jump to latest
			</button>
		</>
	);
}

Next.js

Use the hook only inside a client component.

"use client";

import { useRef, useState } from "react";
import { mergeMessages } from "chat-scroll-behavior";
import { useChatScroll } from "chat-scroll-behavior/react";

export function ChatClient() {
	const containerRef = useRef<HTMLDivElement | null>(null);
	const [messages, setMessages] = useState([]);

	useChatScroll({
		containerRef,
		getMessageId: (message) => message.id,
		onLoadTop: () => loadOlderMessages(),
		onMergeTop: (incoming) => {
			setMessages((current) =>
				mergeMessages(current, incoming, {
					getMessageId: (message) => message.id,
					direction: "prepend",
				}),
			);
		},
	});

	return (
		<div ref={containerRef} style={{ height: "80vh", overflowY: "auto" }}>
			{messages.map((message) => (
				<article key={message.id}>{message.text}</article>
			))}
		</div>
	);
}

API

createChatScrollController(options)

type ChatScrollOptions<TMessage> = {
	container: HTMLElement;
	getMessageId: (message: TMessage) => string | number;
	onLoadTop?: () => Promise<TMessage[]> | TMessage[];
	onLoadBottom?: () => Promise<TMessage[]> | TMessage[];
	onMergeTop?: (messages: TMessage[]) => void | Promise<void>;
	onMergeBottom?: (messages: TMessage[]) => void | Promise<void>;
	onLoadError?: (error: unknown, direction: "top" | "bottom") => void;
	onLoadingChange?: (state: ChatScrollState) => void;
	thresholdPx?: number;
	bottomThresholdPx?: number;
	autoScrollBottom?: boolean;
	hasMoreTop?: boolean;
	hasMoreBottom?: boolean;
	disabled?: boolean;
};

Defaults:

  • thresholdPx: 120
  • bottomThresholdPx: same as thresholdPx
  • autoScrollBottom: true
  • hasMoreTop: true
  • hasMoreBottom: true
  • disabled: false

Controller methods:

type ChatScrollController = {
	check(): void;
	scrollToTop(options?: ScrollIntoViewOptions): void;
	scrollToBottom(options?: ScrollIntoViewOptions): void;
	update(options: Partial<ChatScrollOptions<any>>): void;
	getState(): ChatScrollState;
	destroy(): void;
};

Call check() after manually changing the list if you want the controller to evaluate thresholds immediately.

mergeMessages(existing, incoming, options)

mergeMessages(existing, incoming, {
	getMessageId: (message) => message.id,
	direction: "prepend",
	dedupe: true,
});

dedupe defaults to true. Existing messages stay in their current order. Incoming messages that duplicate existing IDs or earlier incoming IDs are skipped.

useChatScroll(options)

const { isLoadingTop, isLoadingBottom, check, scrollToTop, scrollToBottom } =
	useChatScroll({
		containerRef,
		getMessageId,
		onLoadTop,
		onMergeTop,
		onLoadBottom,
		onMergeBottom,
	});

The hook creates and destroys a controller for the referenced element. It keeps callback references fresh across rerenders.

Scroll Preservation

The core preservation formula is:

nextScrollTop = nextScrollHeight - previousScrollHeight + previousScrollTop;

The controller captures the anchor immediately before your merge runs. That keeps the viewport stable even if the user scrolls while an older-message request is pending.

For virtualized lists, use the low-level helpers:

import { preserveScrollAnchor } from "chat-scroll-behavior";

await preserveScrollAnchor(container, async () => {
	prependRows();
});

Virtualization adapters are intentionally not shipped in v1. This keeps the runtime small while leaving a stable measurement hook for app-specific adapters.

Edge Cases And Best Practices

  • Set hasMoreTop or hasMoreBottom to false when the API reaches the end of history.
  • Keep onLoadTop and onLoadBottom idempotent because scroll events can fire frequently.
  • Edge loads fire once per threshold entry; scroll away from the edge and back to trigger the next page.
  • Use stable message IDs and let mergeMessages dedupe cursor overlap.
  • Keep autoScrollBottom enabled for live chats unless users need strict manual scroll control.
  • Use onLoadError to show retry UI or telemetry in the host app.
  • Destroy vanilla controllers on page teardown or when replacing the scroll container.
  • Do not use smooth scrolling for top preservation. The controller preserves instantly to avoid visual jumps.

Package Development

npm install
npm run check

Before publishing:

npm run build
npm pack --dry-run
npm publish --provenance