Headless React primitives for streamed LLM text: one hook paces the reveal so bursty chunks don't stutter, one keeps a scroll container pinned to the bottom without fighting a user who's scrolled up to reread something, and a markdown renderer that stops re-parsing the part of the message that has already settled.
npm install falconstream
React 18+ as a peer dependency. No provider, no runtime, no CSS.
| Hook | Signature | Returns |
|---|---|---|
useSmoothStream |
(raw: string, isDone: boolean, options?: { charsPerSecond?: number; adaptive?: boolean }) => UseSmoothStreamResult |
{ text: string; isAnimating: boolean; backlogPercent: number; skip: () => void } |
useStickToBottom |
(options?: { thresholdPx?: number }) => UseStickToBottomResult |
{ scrollRef: RefObject<HTMLDivElement>; onScroll: () => void; scrollToBottom: (options?: { force?: boolean }) => void } |
useCopyToClipboard |
(options?: { resetAfterMs?: number }) => UseCopyToClipboardResult |
{ copy: (text: string) => void; status: "idle" | "copied" | "error" } |
charsPerSecond defaults to 60, adaptive (speed up under backlog)
defaults to true. thresholdPx (how close to the bottom still counts as
pinned) defaults to 80.
StreamingMarkdown is imported from falconstream/markdown and needs
react-markdown + remark-gfm installed (optional peer dependencies). The
hooks above have no such requirement.
| Prop | Type | Default |
|---|---|---|
text |
string |
— |
isDone |
boolean |
— |
showCursor |
boolean |
false |
cursorChar |
string |
"▊" |
cursorClassName |
string |
— |
components |
Components |
— |
remarkPlugins |
PluggableList |
[remarkGfm] |
rehypePlugins |
PluggableList |
— |
import { useSmoothStream } from "falconstream";
function AssistantMessage({ raw, isDone }: { raw: string; isDone: boolean }) {
const { text, isAnimating } = useSmoothStream(raw, isDone);
return (
<p>
{text}
{isAnimating && <span className="cursor">▊</span>}
</p>
);
}raw only ever grows, append to it however your own fetch loop already
works (ReadableStream, SSE, whatever your SDK hands you). backlogPercent
is how much of raw hasn't been revealed yet, 0-100 use it to show a
"catching up" indicator.
import { useStickToBottom } from "falconstream";
function MessageList({ children }: { children: React.ReactNode }) {
const { scrollRef, onScroll, scrollToBottom } = useStickToBottom();
// call scrollToBottom() after new content renders,
// scrollToBottom({ force: true }) when the user sends a message
return (
<div ref={scrollRef} onScroll={onScroll} className="overflow-y-auto">
{children}
</div>
);
}import { useSmoothStream } from "falconstream";
import { StreamingMarkdown } from "falconstream/markdown";
function AssistantMessage({ raw, isDone }: { raw: string; isDone: boolean }) {
const { text, isAnimating } = useSmoothStream(raw, isDone);
return (
<StreamingMarkdown
text={text}
isDone={isDone}
showCursor={isAnimating}
cursorClassName="cursor"
/>
);
}Settled blocks are memoized, so only the unfinished tail re-parses as text
arrives. The cursor renders inside the markdown, flush against the last
character, instead of on the line below it style it via cursorClassName.
components and remarkPlugins/rehypePlugins are just passed straight
through to react-markdown. Use components to change how an element
renders, or add a plugin like rehype-highlight or remark-math:
import type { Components } from "falconstream/markdown";
const components: Components = {
a: (props) => <a {...props} target="_blank" rel="noreferrer" />,
pre: (props) => <pre className="rounded-lg bg-neutral-900 p-3" {...props} />,
code: (props) => <code className="rounded bg-neutral-100 px-1" {...props} />,
table: (props) => <table className="w-full border-collapse text-left" {...props} />,
th: (props) => <th className="border px-3 py-2 font-semibold" {...props} />,
td: (props) => <td className="border px-3 py-2 align-top" {...props} />,
};
<StreamingMarkdown text={text} isDone={isDone} components={components} />import { useCopyToClipboard } from "falconstream";
function CopyButton({ text }: { text: string }) {
const { copy, status } = useCopyToClipboard();
return (
<button onClick={() => copy(text)}>
{status === "copied" ? "Copied!" : "Copy"}
</button>
);
}status resets to "idle" after resetAfterMs (default 2000). No toast,
no tooltip — render whatever feedback your app wants from status.
packages/core— the publishedfalconstreampackage.apps/demo— a Next.js chat playground consuming it.
npm install
npm run dev