snibbit is named for the snib, the little catch that holds a door or window fast in one position until you flip it back.
This does that to an AI chat turn: it clicks into the top of the scroll viewport the moment you send, and holds while the reply streams in.
~2 KB min+gzip, zero dependencies, TypeScript, React optional
pnpm add snibbit
bun add snibbit
npm install snibbit
yarn add snibbitreact is an optional peer (v18+); the core needs nothing.
snibbit renders nothing. It reads these hooks off your markup:
| Where | Attribute / ref | Meaning |
|---|---|---|
| Scroll container | scrollRef |
Scrollable element (overflow-y: auto, fixed height). |
| Its first child | Content wrapper; its padding-top is the top breathing room. |
|
| Each turn | data-turn-anchor-id="<id>" |
One user message plus the reply that follows it. |
| Inside each turn | data-turn-measure |
Wraps the measured content. |
Pass the latest turn's id; snibbit hands back a scroll target and a min-height.
"use client";
import { useMemo, useRef } from "react";
import { groupIntoTurns, useSnibbit } from "snibbit/react";
export function Chat({ messages }) {
const scrollRef = useRef(null);
const turns = useMemo(() => groupIntoTurns(messages), [messages]);
const pinTurnAnchorId = turns.at(-1)?.turnAnchorId;
const { latestTurnRef, latestTurnMinHeight } = useSnibbit({
scrollRef,
pinTurnAnchorId,
});
return (
<div ref={scrollRef} style={{ overflowY: "auto", height: "100dvh" }}>
<div style={{ paddingTop: 16 }}>
{turns.map((turn, i) => {
const isLatest = i === turns.length - 1;
const style =
isLatest && latestTurnMinHeight != null
? { minHeight: latestTurnMinHeight }
: undefined;
return (
<section
key={turn.turnAnchorId}
ref={isLatest ? latestTurnRef : undefined}
data-turn-anchor-id={turn.turnAnchorId}
style={style}
>
<div data-turn-measure>
{turn.messages.map((m) => (
<div key={m.id} data-role={m.role}>
{m.content}
</div>
))}
</div>
</section>
);
})}
</div>
</div>
);
}Apply the ref and minHeight to the latest turn only. Next.js: add "use client" (the package ships no directive).
import { measurePinnedTurnConstraint, pinTurnInScrollContainer } from "snibbit";
const scrollEl = document.querySelector("#scroll");
const contentEl = scrollEl.firstElementChild;
let latestTurn = null;
let wasConstrained = true;
function constrain() {
if (!latestTurn) return;
const { minHeightPx, isConstrained } = measurePinnedTurnConstraint({
scrollEl,
sectionEl: latestTurn,
wasConstrained,
});
wasConstrained = isConstrained;
latestTurn.style.minHeight = minHeightPx != null ? `${minHeightPx}px` : "";
}
async function addTurn(id, renderInto) {
const turn = document.createElement("section");
turn.dataset.turnAnchorId = id;
const measure = document.createElement("div");
measure.dataset.turnMeasure = "";
renderInto(measure);
turn.append(measure);
if (latestTurn) latestTurn.style.minHeight = ""; // release the previous turn
contentEl.append(turn);
latestTurn = turn;
wasConstrained = true;
await new Promise((r) => requestAnimationFrame(r));
constrain(); // reserve height first,
pinTurnInScrollContainer({ scrollEl, sectionEl: turn }); // then pin to the top
}Re-run constrain() as the reply streams and on resize. Full version: demo/index.html (npm run demo).
<script src="https://unpkg.com/snibbit/dist/snibbit.global.js"></script>
<script>
const { pinTurnInScrollContainer, measurePinnedTurnConstraint } = snibbit;
</script>Optional on useSnibbit and the core functions:
| Option | Default | Does |
|---|---|---|
getHeaderOverlapPx(scrollEl) |
0 |
Pixels a sticky header overlaps the top. |
getContentPaddingTopPx(scrollEl) |
first child's padding-top |
Override the top padding lookup. |
shouldReduceMotion |
false |
Jump instead of animate (wire to prefers-reduced-motion). |
animationDurationMs |
440 |
Scroll animation length. |
animationEasing |
ease-out cubic | Custom easing. |
hysteresisPx |
6 |
Dead-band so min-height doesn't flap. |
layoutSettlingFrames |
3 |
(React) Frames to wait before pinning. |
MIT