Display
Stick To Bottom
A scroll container that follows its own bottom edge — appended content stays in view until the reader scrolls up, and then the position is theirs, with a counted way back.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/stick-to-bottom.jsonPrompt
Build a React + TypeScript + Tailwind "StickToBottom" component with lucide-react.
Contract
- A forwardRef div extending React.HTMLAttributes<HTMLDivElement>. The ref and the
spread props land on the SCROLLING element itself, not on a wrapper — consumers
size it (h-80, flex-1 min-h-0, max-h-[60vh]) and drive it from the outside, and
`ref.current.scrollTop = ref.current.scrollHeight` is a legal way to resume
following because it arrives at the bottom, which is what the container watches.
- Props: children; threshold?: number (24) — px above the true bottom that still
count as "at the bottom", clamped to >= 0 with a non-finite value falling back to
the default; showJumpButton?: boolean (true); unreadCount?: number — overrides the
badge, and left undefined the container counts direct children appended while it
was not following; jumpLabel?: string ("Jump to latest"); label?: string
("Scrollable content", the accessible name); onFollowChange?: (following) => void.
className merges through cn().
- No data contract: the component renders none of the content, only decides who owns
the scroll position.
Behavior
- Following is a band, never an equality test. scrollHeight and clientHeight are
integers while scrollTop is fractional, so at 110% zoom or on a HiDPI screen a
container that is visually glued to the bottom still reports about a pixel of gap;
comparing against 0 makes it decide the reader left the moment it paints.
- Telling a user scroll from a scroll the container caused is the whole problem, and
a scroll event carries no origin. Three facts settle it:
· content growing below the viewport never LOWERS scrollTop — only the reader does,
so scrollTop decreasing is the reader taking over;
· a scroll event that lands exactly on the position the last pin wrote means
nothing has moved the viewport since; the event is the delayed echo of that write,
and the container catches up to the newer height instead of reading the (stale)
gap as intent. Scroll events are delivered a frame late, so two images decoding
one frame apart is enough to produce a gap the reader never made;
· while a smooth jump the container itself started is travelling, its intermediate
positions belong to it, and it re-aims if the bottom moves underneath it.
- Once handed over, the container touches scrollTop for nothing. Content grows below
the fold, the reader's position is bit-for-bit unchanged, and the arrivals surface
as a jump button carrying a count.
- Three growth channels, because each is blind to the others: a layout effect after
every commit (React-driven appends, pinned BEFORE paint — the observers are
installed in a passive effect and therefore cannot cover the first frame); a
MutationObserver on the subtree (appended children, streamed text, and the direct
child additions the unread count derives from); a ResizeObserver on the viewport
AND on every direct child (a late image, a late font, a growing composer — those
fire no scroll event, mutate no attribute the observer watches, and trigger no
React render, so nothing else would ever hear about them). MutationObserver keeps
the ResizeObserver's target set in sync as children come and go, and both plus the
scroll listener are torn down on unmount.
- Every follow pin is INSTANT. A smooth animation restarted on each streamed token
never finishes, and the text wobbles under its own follow animation; the pin
happens pre-paint in the same frame the content lands, so there is nothing to see.
Only the jump button animates, and only when the reader is not reduced-motion.
- The reader always wins, including mid-animation: Chromium does not let a wheel
gesture abort scrollTo({ behavior: "smooth" }) — the animation runs to completion
and drags them back down — so a wheel / touchmove / scroll-key event cancels the
container's own animation by writing scrollTop instantly, which does abort it.
- overflow-anchor: none on the scroller: the browser's own scroll anchoring competes
with the pin on an append-only stream and yanks the viewport around.
Rendering & styling
- Semantic tokens only: bg-card + border + shadow-sm for the jump pill,
hover:bg-muted, text-xs font-medium, focus-visible:ring-2 ring-ring on the button
and focus-visible:ring-inset on the scroller (it clips its own overflow). The
container itself paints nothing — no background, no border, no padding — so it can
wrap content that already has a look.
- The jump button lives in a zero-height sticky strip parked at the end of the
content: sticky pins it to the bottom of the SCROLLPORT, while an absolutely
positioned child of a scroll container would sit at the bottom of the CONTENT and
only appear once you had already scrolled there. Zero height plus absolute
positioning means it contributes nothing to scrollHeight.
- The scroller is tabIndex 0 only while it actually overflows (it carries the arrow /
PageUp / PageDown keys; an empty tab stop is noise) and takes an aria-label from
`label`. data-following="true|false" is exposed for styling and tests. The button's
accessible name includes the count, and its entrance carries motion-reduce:
animate-none; under prefers-reduced-motion the jump is an instant landing.
Customization levers
- threshold is the whole feel: 8 for a log console that should only follow when you
are truly at the end, 24 for chat, 48-64 for touch where momentum overshoots.
- The unread badge is hybrid: leave unreadCount off and direct-child additions are
counted for you; pass your own number when "one item" is not "one direct child"
(your rows are wrapped in a single list element, or an item spans several nodes).
showJumpButton={false} drops the control entirely and onFollowChange lets you build
your own affordance outside the scroller.
- Skin the pill wherever it needs to match: bg-primary/text-primary-foreground for a
loud "N new messages", or an icon-only round button for a dense console.
- Layout is yours: pass p-4 for a chat, font-mono text-xs for a console. If you make
the container a flex column, give its items shrink-0 — an overflowing column flex
container shrinks its items to min-content, which silently flattens an image row;
and add justify-end if short content should sit at the bottom instead of the top.
- No role is imposed, because the content is not the container's to describe: pass
your own (role="log" + aria-live="polite" for a transcript, role="list" for rows)
through the spread props, and replace `label` with your own aria-label.
- Pair it with chat-composer / prompt-input underneath: this component deliberately
owns no input, no data and no empty state.Concepts
- The bottom is a band, not a line —
scrollHeightandclientHeightare integers whilescrollTopis fractional, so a container glued to the bottom still reports a pixel or so of gap at 110% zoom or on a HiDPI screen. A tolerance of a couple of dozen pixels is what stops "at the bottom" from being false on the very first paint, and it doubles as how far the reader must travel before the container lets go. - A scroll event carries no origin — the browser reports that the position changed, never who changed it, and the event arrives a frame after the fact. Three signals recover the answer: content growth never lowers
scrollTop, so a decrease is the reader; an event landing exactly on the position the last pin wrote is that pin's delayed echo, not intent; and an animation the container started owns its own intermediate frames. - Three growth channels, each blind to the others — a React commit is caught by a layout effect (the only one that can cover the first painted frame, since observers are installed after it); appended children and streamed text are caught by a
MutationObserver; and a late image or font, which fires no scroll event and triggers no render, is caught only by aResizeObserveron each direct child. Drop any one and a real-world append stops being followed. - Instant pins, animated only on request — following writes
scrollTopsynchronously before paint, in the same frame the content lands, so there is no interpolation to see and streamed tokens cannot wobble. Only the deliberate jump back animates, andprefers-reduced-motionturns that into a landing. - The reader wins, even mid-animation — Chromium will not let a wheel gesture abort
scrollTo({ behavior: "smooth" }); the animation finishes and drags them back down. So a real input event cancels the container's own animation by writingscrollTopinstantly, which does abort it. "Hands the position over" is only true if it is also true while the container is moving. - A zero-height sticky strip — the jump control is parked at the end of the content inside a
sticky bottom-0 h-0box, so it hangs off the bottom edge of the scrollport. An absolutely positioned child of a scroll container would sit at the bottom of the content and only become visible once you had already scrolled to it — and because the strip has no height and the button is out of flow, neither adds a pixel toscrollHeight.
Scroll Shadow
A scroll container whose edges dissolve wherever the content continues — all four sides judged independently, drawn as a mask on the scroller so nothing overlays the content.
Lazy Render
Defers mounting a subtree until it scrolls into view or the browser goes idle, holding its space with an explicitly reserved placeholder height.