Scroll To Latest
A floating jump-to-latest pill for a growing chat viewport — a sentinel decides when the reader has left the floor, arrivals since then ride in the badge, and one click re-pins follow mode.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/scroll-to-latest.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "scroll to latest" pair with lucide-react:
a headless hook `useFollowBottom` that owns the follow → detach → re-pin state
machine, and a presentational `ScrollToLatest` pill that renders its result.
Two exports, because the behaviour belongs to the scroll container and the
affordance belongs to the design — most projects already have one of the two.
Contract — useFollowBottom(options) -> FollowBottom
- options.count?: number (default 0) — how many items the thread holds.
Arrivals while the reader is detached become `unseen`. It only has to be
non-decreasing while the reader is away; a SMALLER value means "the thread
was replaced" and resets the count instead of going negative.
- options.threshold?: number (default 32) — px from the floor that still count
as "at the latest". It is both the release zone and the re-arm zone.
- options.initialFollow?: boolean (default true) — read once, on mount.
- options.onFollowChange?: (following: boolean) => void.
- Returns { viewportRef, contentRef, sentinelRef, following, unseen,
scrollToLatest({ smooth? }) }.
- The three refs are CALLBACK refs backed by node state, not RefObjects: a
transcript usually renders a skeleton first and swaps the real viewport in a
tick later, and a RefObject leaves the observers bound to a node that is gone.
Type them RefCallback<HTMLElement> so they fit a div, a ul or a section.
- DOM contract, stated in the JSDoc because the hook cannot enforce it:
viewport (overflow-y-auto) > content (the element that grows) > … > sentinel
as the LAST child of content, 1px tall, aria-hidden.
Contract — <ScrollToLatest />
- forwardRef<HTMLButtonElement>, extends React.ButtonHTMLAttributes; spread the
rest LAST so a consumer can override aria-label, tabIndex or onClick.
- count?: number (default 0), visible?: boolean (default true),
label?: string ("Jump to latest"), countLabel?: string ("New messages"),
max?: number (99), align?: "start" | "center" | "end" ("center"),
collapse?: boolean (false), variant?: "solid" | "soft" ("solid"),
announce?: boolean (true).
- It performs NO scrolling of its own: the consumer wires onClick to
scrollToLatest. A pill that scrolled "the nearest scroll parent" would guess
wrong the first time it sits inside a dialog.
Behavior — deciding "is the reader at the latest?"
- Do NOT answer this with a scroll listener. Reading
scrollHeight - scrollTop - clientHeight on every scroll event forces layout on
the one interaction that must stay smooth.
- Instead observe the 1px sentinel with an IntersectionObserver whose root is
the viewport and whose rootMargin is `0px 0px ${threshold}px 0px` — growing
the root box downward by the threshold makes "intersecting" mean exactly
"within `threshold` px of the floor". threshold: 0, so the callback fires only
on the two transitions and never in between.
- Clamp that bottom margin to at least 1px. At exactly 0 the sentinel sits on
the root's own edge once the viewport reaches the floor, the intersection is
empty, and "back at the latest" simply stops being reported — the reader can
then only re-arm follow through the pill. The 1px also absorbs the fractional
scrollTop browsers report at non-integer zoom.
- isIntersecting -> FOLLOWING: clear the smooth-jump window, set seen = count,
unseen = 0, re-arm the pin.
- not intersecting -> read the real distance once and bail out if it is still
within the threshold (the pin may not have landed yet for this frame);
otherwise DETACHED: freeze `seen`, so unseen = count - seen counts arrivals
since the takeover rather than a tally that can drift from the list.
Behavior — staying at the floor
- Appending content changes scrollHeight and never scrollTop, so following is an
active job: a ResizeObserver on the content element re-assigns
scrollTop = scrollHeight - clientHeight on every growth, and it is an
ASSIGNMENT, never behavior:"smooth" — a smooth pin animates across frames, so
the next token lands mid-animation and the transcript rubber-bands.
- Observe the viewport with the same observer: an on-screen keyboard or a
resized panel shrinks the box without touching the content, and that must not
drop the floor.
- The pairing is what makes it robust: within one frame the browser runs resize
observations BEFORE intersection observations, so a growth frame is already
pinned by the time the sentinel's visibility is computed and growth can never
be misread as the reader scrolling away. Verify it by deleting the resize
pin — the pill starts flashing on every token.
- Write scrollTop through a ref-held node, never through the node captured in
the render scope: mutation belongs to refs, and it keeps the React Compiler's
immutability rule satisfied.
Behavior — one-shot opening pin
- A layout effect (isomorphic: fall back to useEffect when document is
undefined, or React warns during SSR) pins to the floor on the FIRST commit
that has a viewport, guarded by a ref that is set before the pin. The lock
matters more than the pin: without it, any later commit that re-runs the
effect would yank a reader who had scrolled up. Skip it when
initialFollow is false.
Behavior — the jump
- scrollToLatest re-arms follow OPTIMISTICALLY, before the travel finishes:
content arriving during the flight has to be pinned too, or the animation
lands above the new floor and the pill immediately comes back.
- Smooth by default; instant when matchMedia("(prefers-reduced-motion: reduce)")
matches or when called with { smooth: false }. Read the query at call time —
no subscription, nothing to clean up.
- While a smooth jump is in flight (a timestamp budget of ~1200ms), the resize
pin RE-TARGETS with scrollTo({ behavior:"smooth" }) instead of assigning, so a
reply that streams in during the travel extends the same animation. The budget
is a timestamp, not a timer, so there is no handle to leak; landing at the
floor clears it early.
- Cleanup: both observers are created in effects keyed on the node + threshold
and disconnected in the teardown. There are no timers and no scroll listeners
in the whole component.
Behavior — the pill
- Always mounted; `visible` only toggles opacity + a small translate-y so the
fade can play both ways. While hidden it is aria-hidden, tabIndex={-1} and
pointer-events-none — never a target the reader cannot see.
- count is clamped to a non-negative integer; above `max` the badge reads
"99+" while the accessible name keeps the exact number ("Jump to latest, 128
new messages", singular at 1).
- The visible text is aria-hidden and the button carries a composed aria-label,
so a screen reader hears one sentence instead of "New messages 3".
- collapse: with nothing waiting, shrink to a circular size-9 arrow whose only
accessible name is `label` — the labelled pill is reserved for arrivals.
- A separate sr-only <span role="status" aria-live="polite"> sits OUTSIDE the
button and carries the sentence only while the pill is visible with a count:
the affordance FOR a new message must not be announced AS one, and the region
has to keep speaking while the button itself is aria-hidden.
Rendering & styling
- Semantic tokens only: bg-primary / text-primary-foreground /
hover:bg-primary/90 (solid), bg-card + border + hover:bg-muted (soft),
bg-primary-foreground/20 for the badge inside the solid pill,
focus-visible:ring-2 ring-ring ring-offset-2 ring-offset-background. No hex,
no rgb(), no oklch().
- The pill is `absolute bottom-3` plus one of left-3 / left-1/2 -translate-x-1/2
/ right-3, and cn() merges the consumer className LAST — override `absolute`
with `sticky` or `fixed` when the viewport is the page itself.
- The positioned ancestor belongs to the consumer: wrap the viewport in a
`relative` element. Say so in the JSDoc; a pill that creates its own wrapper
cannot be placed above an input bar.
- max-w-[calc(100%-1.5rem)] + truncate so a translated label never overflows a
narrow panel.
- motion-reduce:transition-none on the fade; the jump degrades to an instant
one. Nothing about the affordance turns off with animation.
Customization levers
- threshold (32): the release AND re-arm zone in one number. Raise it for
touch-heavy surfaces where a 20px flick should not count as leaving; keep it
above ~8 or fractional scrollTop at non-integer zoom will chatter.
- Smooth budget (1200ms): how long after a click the pin keeps re-targeting
smoothly instead of snapping. Lower it for very fast streams.
- count source: pass items.length for a message list, or your own monotonic
arrival counter when the list is trimmed at the top (a shrinking count is read
as a thread swap and resets the badge).
- Pill chrome: variant solid/soft, align start/center/end, collapse for the
circular idle form, countLabel/label for i18n, max for the clamp. Swap
ArrowDown for your own glyph, or drop the badge and keep the count in the
accessible name only.
- Placement: bottom-3 assumes the pill floats over the transcript. Raise it
above a composer with bottom-20, or set align="end" to clear a centered
"regenerate" control.
- announce={false} when the transcript already owns a role="log" live region —
otherwise a screen reader hears both the arrival and the affordance.
- The hook is independent of the pill: keep useFollowBottom and render your own
chrome (a toast, a divider line, a chevron in the header) from `following` and
`unseen`.Concepts
- Sentinel instead of scroll math — "am I at the latest?" is answered by a 1px element at the end of the content, watched against the viewport with the root box grown downward by the threshold. The observer fires only on the two transitions, so the question costs nothing on the frames in between — a scroll listener would force a layout read on every event of the one gesture that must stay smooth.
- Pin before paint — appending changes
scrollHeightand neverscrollTop, so staying at the floor is an active job done by a ResizeObserver that assigns the position. A smooth pin is the opposite: it animates across frames, the next token lands mid-animation, and the view visibly rubber-bands. - Resize-then-intersect — the two observers are ordered by the browser itself: within a frame, resize observations run before intersection observations. The growth frame is therefore already pinned when the sentinel's visibility is computed, which is why content arriving can never be mistaken for the reader scrolling away.
- Unseen since takeover — the badge is
count - seen, whereseentracks the thread while following and freezes the instant the reader detaches. It counts arrivals since the takeover rather than keeping a tally that drifts, and a count that shrinks is read as "the thread was replaced" and resets rather than going negative. - Optimistic re-arm — the click re-arms follow before the travel finishes, and while the smooth jump is in flight the pin re-targets the same animation instead of assigning. Without both, a reply that streams in mid-flight leaves the jump short of the new floor and the pill reappears the moment it landed.
- One-shot opening pin — a thread opens on its newest message with no visible travel, and the lock that makes it happen only once is the important half: a re-runnable pin would yank a reader who had already scrolled up.
- The affordance is not the message — the polite live region lives outside the button and speaks only while the pill is showing, so a screen reader hears "3 new messages" as an offer to jump, not as the messages themselves.
Conversation Summary
A compaction seam for long chats — a hairline marker that reports how many earlier messages were summarized, discloses the bullet summary, and offers a restore that can only fire once.
Read Aloud Button
A per-message listen toggle — progress ring, tiny equalizer and a speed pill — driven by the browser's speech engine or by your own TTS callbacks.