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…

"use client"

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

/**
 * How close to the true bottom still counts as "pinned", in px. It has to be a band and not an
 * equality test: scrollHeight/clientHeight are integers while scrollTop is fractional, so on a
 * HiDPI screen or at 110% browser zoom a container that is visually glued to the bottom still
 * reports a gap of a pixel or so. Comparing for 0 there means the container decides the reader
 * has scrolled away the instant it is painted.
 */
const DEFAULT_THRESHOLD = 24

Installation

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

Prompt

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 linescrollHeight and clientHeight are integers while scrollTop is 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 a ResizeObserver on each direct child. Drop any one and a real-world append stops being followed.
  • Instant pins, animated only on request — following writes scrollTop synchronously 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, and prefers-reduced-motion turns 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 writing scrollTop instantly, 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-0 box, 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 to scrollHeight.

On This Page