Skip to content

Latest commit

 

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

snibbit

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

Install

pnpm add snibbit
bun add snibbit
npm install snibbit
yarn add snibbit

react is an optional peer (v18+); the core needs nothing.

Setup

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.

React

"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).

Vanilla JS

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 tag

<script src="https://unpkg.com/snibbit/dist/snibbit.global.js"></script>
<script>
  const { pinTurnInScrollContainer, measurePinnedTurnConstraint } = snibbit;
</script>

Options

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.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages