use-stick-to-bottom ships zero dependencies, no tests, and a peer range wider than its dev setup
A lightweight React Hook intended mainly for AI chat applications, for smoothly sticking to bottom of messages
At a glance
- What is it?
- A single-purpose React hook that keeps a chat container pinned to the bottom while streamed content arrives, implemented in JavaScript because Safari lacks CSS scroll anchoring. The manifest declares React 19 support the development environment never installs, the repository carries two lockfiles, and its package metadata still points at the original author's GitHub.
- Who is it for?
- Adopt this if you are building a streaming chat interface and want stickiness, scroll anchoring and a cancel-on-scroll-up behaviour handled without pulling in a scrolling library, and if you are content to own the verification. Two checks come first.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 129 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 4, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The zero dependency claim is verifiable from the manifest
The headline claim is a lightweight zero-dependency React hook plus component, and the install line is one command:
npm install use-stick-to-bottomThat claim is checkable without installing anything. The package manifest has no `dependencies` entry at all, which is stronger than a small dependency list: there is nothing to resolve at install time except the peer dependency. Runtime cost is React and whatever the browser provides.
The rest of the manifest is disciplined in the same spirit. `sideEffects` is false, which lets a bundler drop unused imports. The `files` array is a single entry, `dist`, so the published tarball carries the build output and nothing else from the repository. `type` is `module`, `license` is MIT, and `LICENSE.txt` is in the root to back it up.
That matters more than usual for a hook that sits between a user and a scroll container. A stick-to-bottom implementation has to observe size changes and intercept scroll events, and every one of those is somewhere a library would otherwise reach for a helper. Doing it with `ResizeObserver` and custom animation frames keeps the whole mechanism inspectable in one file, which is the practical argument for the design rather than the word lightweight.
React 19 is in the peer range and absent from the dev setup
The peer dependency is a wide range: `^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0`. Hooks arrived in React 16.8, so the floor is the oldest version that can run a hook at all, and four majors are covered above it.
The devDependencies tell a narrower story. There, `react` is `^16.8.0 || ^17.0.0 || ^18.0.0` with no 19 in the range, and `react-dom` is pinned to `^18.0.0`. The demo app, the type definitions at `@types/react` 18.3.3, and every local build therefore run against React 18 at most. React 19 is declared supported by the published package and never developed against in this repository.
That gap is invisible to a consumer reading the README and it is exactly the kind of declaration that breaks quietly, because a peer range is a promise the maintainer is making on behalf of every version inside it. The fix is not necessarily a version bump; it is running the demo against the newest supported major before the range claims it. Nothing on the page says whether that has been done.
No test script, no framework, and a demo that generates its own messages
The `scripts` block has five entries: `dev` runs vite, `build` is `rm -rf dist > /dev/null 2>&1; tsc -b`, `prepublishOnly` is `pnpm build`, `lint` is `biome check`, and `lint:fix` is `pnpm lint --write`. There is no `test` entry, no test framework in devDependencies, and no test directory in the repository tree.
What is in the tree instead is a demo application: `index.html`, `vite.config.ts`, `demo/Demo.tsx`, `demo/index.tsx`, `demo/index.css` and `demo/useFakeMessages.tsx`, plus a `tsconfig.demo.json` for it. The module named `useFakeMessages` is the giveaway, and the `lorem-ipsum` devDependency confirms it: the demo manufactures streaming text locally instead of talking to a model.
That is a reasonable thing to ship, and it is also the only verification surface. The behavioural claims on the page are specific: content shrinking does not lose stickiness, scroll anchoring is handled when content above the viewport resizes, user scrolling is distinguished from animation scroll events without debouncing, and mobile behaves well. Each of those is a scenario a demo can demonstrate and none of them is a regression anyone will notice when it breaks after a React or browser update. Treat the demo as documentation, not as a test suite.
Two lockfiles in the tree and a pinned pnpm in the metadata
The repository root carries both `pnpm-lock.yaml` and `yarn.lock`. Alongside them, `package.json` declares `packageManager` as `[email protected]` with an integrity hash, and the publish hook is `prepublishOnly: pnpm build`.
So the intended package manager is unambiguous even though the tree is not. Two lockfiles means two resolution histories, and only one of them can be the one the published artifact was built from. Which is which is not documented, and it is the kind of detail that matters when a consumer reports a resolved version you cannot reproduce locally.
The build command is worth reading too. It clears `dist` and silences the removal, then runs `tsc -b`, which builds project references rather than a single compilation. That fits the four TypeScript configs in the root: `tsconfig.json`, `tsconfig.app.json`, `tsconfig.demo.json` and `tsconfig.node.json`, the split a Vite template produces. So the library types, the demo and the tooling config are three separate compilation targets and only some of them ship.
One oddity follows from that. `vite-plugin-dts` is a devDependency, and its job is generating declaration files, while the package points `types` at `dist/index.d.ts` and the build script never invokes a bundler at all. The declarations are coming from `tsc`, not from the plugin whose presence in the manifest implies otherwise.
The package metadata still points at the original author's GitHub
The code lives under the stackblitz-labs organization. The manifest's `repository` field points somewhere else: `https://github.com/samdenty/use-stick-to-bottom.git`, with the type set to git. The homepage is `use-stick-to-bottom.samdenty.io`, the funding entry is a GitHub Sponsors URL under `samdenty`, and the README badges link an account by the same name.
None of that is automatically wrong. An author who moved a project into an organization may well keep the npm package, the domain and the funding under their own name, and the package name on npm is unchanged. But the practical consequence is that a reader who compares a GitHub URL with npm metadata sees two different owners, and a reader trying to file an issue has to work out which repository is authoritative.
The version story has a related gap. There are no GitHub releases for this project, so the published versions are npm versions only, with `1.1.6` as the number in the manifest. A `CHANGELOG.md` exists in the root and is the one version history available, and it is not referenced from the README. So the sequence from 1.1.0 to 1.1.6 has to be read out of a file the front page does not point to.
The Safari gap is why the scroll logic is written by hand
The first feature bullet is a browser limitation rather than a feature. CSS `overflow-anchor` is what a browser would normally use to keep content stable when things above the viewport resize, and Safari does not support it. So the hook does the work in JavaScript instead, using `ResizeObserver` to detect when content resizes, including when content shrinks rather than only when it grows.
The second mechanism is telling user intent from machine intent. Stickiness is cancelled by the user scrolling up, but the hook's own animation also produces scroll events, so it has to distinguish them. The stated approach does so without debouncing, specifically because debouncing would drop events, and the page claims the logic holds up on mobile devices where the two are harder to tell apart.
The animation itself is a deliberate rejection of the common approach. Instead of an easing function with a fixed duration, it uses a custom smooth scrolling algorithm with velocity-based spring animations and configurable parameters. The reason given is duration-based easing, which is what other libraries use, behaving badly when new content streams in at variable sizes, which is the normal case for a chatbot.
The API surface reflects that design. `scrollToBottom` returns a `Promise<boolean>` that resolves to true as soon as the scroll succeeds and false if it is cancelled, so a caller can await the animation instead of guessing when it ended.
Two ways to adopt it, and the component example is the one that stops halfway
There are two entry points. The bare hook returns a pair of refs, `scrollRef` and `contentRef`, which you attach to your own scroll container and content wrapper, with `overflow: auto` supplied by your own inline style. Nothing about your markup changes beyond those two attributes.
The component wraps that. `<StickToBottom>` takes a `className`, a `resize` prop set to `smooth` in the example, and an `initial` prop also set to `smooth`. It provides context as well, so children can read `isAtBottom` and scroll programmatically, which is what the example's input box uses to jump to the bottom when a message is entered. The component version also handles the refs for you.
Two observations. Both string props appear only with the value `smooth`, and the page gives no list of what the other accepted values are, so the choice between an instant and an animated update on resize is left to whoever reads the types. And the component example is the truncated one: the code block stops partway through defining its second helper component, immediately after the first example's closing tag. The hook example below it ends cleanly with a full component. So the page documents the context API by naming it in a comment and then cuts off before showing the code that consumes it.
Editorial conclusion
Adopt this if you are building a streaming chat interface and want stickiness, scroll anchoring and a cancel-on-scroll-up behaviour handled without pulling in a scrolling library, and if you are content to own the verification. Two checks come first. There is no test script, no test framework and no test directory in the package manifest or the tree, so the correctness claims about Safari, scroll anchoring and scroll event disambiguation rest on the demo app rather than on assertions. And the peer dependency advertises React 19 while the dev environment installs React 18, so validate against the version you actually ship. Read the publish metadata too, because the repository URL still names the original author's GitHub rather than the organization this code now lives in, and the two lockfiles in the tree mean it is not obvious which package manager produced the published build.
Frequently asked questions
Does use-stick-to-bottom have any runtime dependencies?
None. The package manifest has no dependencies entry at all, only a peer dependency on React, and the published files array contains a single entry, dist. The claim of zero dependencies is verifiable from the manifest rather than from marketing copy.
Which React versions does use-stick-to-bottom support?
The peer dependency range is `^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0`. The development environment is narrower, with react capped at the same range without 19 and react-dom at `^18.0.0`, so React 19 is declared supported but never built against here.
Why does use-stick-to-bottom avoid CSS overflow-anchor?
Because Safari does not support it, according to the feature list. The hook does the work in JavaScript with ResizeObserver, detects content resizing including shrinkage, and handles scroll anchoring so that content above the viewport resizing does not move what is currently in view.
Does use-stick-to-bottom have automated tests?
No test script and no test framework appear in the package manifest or the devDependencies, and there is no test directory in the tree. The verification surface is the demo application under demo/, which includes a useFakeMessages module and an lorem-ipsum dependency for generating its own streaming text.
What does scrollToBottom resolve to in use-stick-to-bottom?
It returns a `Promise<boolean>` that resolves to true as soon as the scroll succeeds, or false if the scroll was cancelled. Stickiness itself can be cancelled at any point by the user scrolling up, which is detected separately from the hook's own animation events without debouncing.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/stackblitz-labs-use-stick-to-bottom)