AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowDown } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * useFollowBottom — follow → detach → re-pin, decided by a sentinel
 *
 * A transcript that grows while you read it has exactly one hard question:
 * "is the reader still at the latest message?" Answering it with a scroll
 * listener means measuring `scrollHeight - scrollTop - clientHeight` on every
 * scroll event — a forced layout per event, on the one interaction that must
 * stay smooth.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/scroll-to-latest.json

Prompt

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 scrollHeight and never scrollTop, 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, where seen tracks 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.

On This Page