Topic 2.7
Scrolling While Text Arrives
In one line
Follow new text only while the reader is at the bottom. If they scroll up, stop following and offer a Jump to latest button. Use a small threshold, scroll before paint, and plan for phone keyboards and for browsers that scroll for you.
Think of it like this
Reading a long receipt as it prints out of a machine. If you are watching the newest line, you pull the paper along. If you bend down to re-read an earlier item, you would be annoyed if someone kept yanking the paper away from you. Auto-scroll should help the reader who is at the end and leave alone the reader who is not.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- scrollTop
- How many pixels the content has been scrolled from the top.
- scrollHeight
- The full height of the content, including the part not visible.
- clientHeight
- The height of the visible box.
- Threshold
- A small distance from the bottom that still counts as being at the bottom.
- useLayoutEffect
- A React effect that runs after the page updates and before the browser paints.
- Scroll anchoring
- A browser feature that keeps visible content from jumping when content above changes.
- 100dvh
- A height unit that follows the browser's own toolbars as they show and hide on a phone. By default it does not shrink for the on-screen keyboard.
- visualViewport
- A browser object that reports the part of the page the reader can actually see, which shrinks when the keyboard opens. It fires a
resizeevent. - Jump to latest
- A button that takes a reader who scrolled up back to the newest text.
Step by step
01Follow only when at the bottom
The first example streams twelve lines into an 80 pixel tall box. It follows the new text while the reader is at the bottom, then a scripted reader scrolls to the top. The Jump to latest button appears, more text arrives, and the view stays where the reader put it. Pressing the button returns to the bottom and the button disappears.
const THRESHOLD = 24;
const distanceFromBottom = (el) => el.scrollHeight - el.scrollTop - el.clientHeight;
function onScroll() { // 1. remember where the reader is
following.current = distanceFromBottom(box.current) < THRESHOLD;
setShowJump(!following.current);
}
useLayoutEffect(() => { // 2. after new text, before paint
if (following.current) box.current.scrollTop = box.current.scrollHeight;
}, [text]);
// 3. <button onClick={() => (box.current.scrollTop = box.current.scrollHeight)}>Jump to latest</button>02The decision on its own
The second example is the same rule as a pure function, run over six situations. It also prints what this browser supports: scroll anchoring, 100dvh and visualViewport. A strict === 0 test fails on the sub-pixel case, which is why the threshold exists.
03Why scroll before paint
React updates the page, then runs layout effects, then the browser paints. Scrolling in useLayoutEffect therefore changes the scroll position before the reader's screen is drawn, so the new line is already in view in the very first frame that shows it.
04Pixels are not for tests
The checking frame of these pages is tiny, so the example logs decisions (true or false), never pixel positions. Pixel values depend on the screen and would differ for every reader.
05On phones
Open the on-screen keyboard and the visible area shrinks, but by default only the visual viewport does. Size the chat with 100dvh (not 100vh) so the phone's toolbars do not hide the input, keep the input at the bottom of that area, and add interactive-widget=resizes-content to the viewport meta tag or follow visualViewport so the keyboard does not cover it. Test with the keyboard open. If the reader was following, scroll to the bottom again when the viewport resizes.
Try it yourself
- 1
Raise the threshold
In the pure function example change
threshold = 24to300. The 200 px scrolled-up reader now counts as at the bottom, and the linereader scrolled up 200 px:saysfollowinstead ofleave alone. A threshold larger than a screenful makes scrolling up impossible while text streams. - 2
Scroll on the wrong effect
In the first example change
useLayoutEffecttouseEffectand run it by hand, scrolling slowly while it streams. On slow devices you may see the new text flash below the fold for a frame before the box scrolls. - 3
Open the keyboard
On a phone, open a chat page whose outer box is
height: 100dvh, focus the input and watch where the keyboard lands: it can still cover the input, because by default it shrinks only the visual viewport. Then add<meta name="viewport" content="width=device-width, interactive-widget=resizes-content">and compare. Browser support for that setting differs, so test on the phones you ship to.
Code & diagrams
The script scrolls the box for you. Try it by hand: while it streams, scroll up in the box and watch the button appear.
Preview
Console
Nothing logged yet.Expected console output
mid-stream, following the new text: true | jump button: false
reader scrolled up, jump button shown: true
more text arrived, the view stayed where the reader put it: true | box has more text than fits: true
after Jump to latest, at the bottom: true | button gone: trueThe support lines depend on the browser that runs the example. They are true in current browsers; older ones may print false. Note that dvh being supported does not mean it reacts to the on-screen keyboard: by default it does not.
Preview
Console
Nothing logged yet.Expected console output
empty list, nothing to scroll: follow
short answer, fits without scrolling: follow
exactly at the bottom: follow
sub-pixel rounding (high-DPI screen): follow
10 px above the bottom: follow
reader scrolled up 200 px: leave alone
strict === 0 check on the sub-pixel case: false (why a threshold is needed)
scroll anchoring (overflow-anchor): true
dynamic viewport units (100dvh): true
visualViewport API (keyboard size): objectBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Always scroll
In the first example, change the layout effect to box.current.scrollTop = box.current.scrollHeight; with no if (following.current).
Myth vs fact
Myth
Always scrolling to the bottom is the safe default.
Fact
It makes re-reading impossible while an answer streams. Follow only a reader who is at the bottom.
Myth
At the bottom means scrollTop equals the maximum exactly.
Fact
Fractions and small offsets happen. Use a threshold.
Myth
100vh is the height of the visible screen on phones.
Fact
Mobile browser toolbars and the keyboard change the visible area. 100dvh follows the toolbars; for the keyboard use interactive-widget=resizes-content or visualViewport. Test with the keyboard open.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Scrolling should also respect reduced motion: use instant scrolling during streaming. Smooth scrolling on every token is slow and makes some users unwell.
- ▸
A live region that announces every token is unusable with a screen reader. Mark the message list as
role="log"and announce completion, not each word (Topic 10.6 covers this). - ▸
Do not store scroll position in state; it changes constantly. A ref plus one boolean for the button is enough.
Remember this
- 1
The problem this solves: Without scrolling, new text grows below the visible area and the reader has to chase it. With naive always-scroll, the page jumps to the bottom every few milliseconds, so nobody can scroll up to re-read while an answer is still arriving. This topic builds the rule that serves both.
- 2
The rule. Before new text is added, ask: is the reader at the bottom? If yes, after adding it, scroll to the bottom. If no, do nothing, and show a button "Jump to latest". Compute it from three numbers on the scrolling element:
scrollHeight(total content height),scrollTop(how far scrolled) andclientHeight(visible height). The distance from the bottom isscrollHeight - scrollTop - clientHeight. - 3
Use a threshold, not zero. On high-density screens the numbers can be fractions (
699.5), and a reader may be a few pixels from the bottom without noticing. Treat a distance of up to about 24 pixels as "at the bottom". The second example tests the decision as a plain function across empty lists, short answers, exact bottom, sub-pixel values and scrolled-up readers. - 4
Remember the decision in a ref, update it on scroll. A
scrollevent fires when the reader scrolls (and also when your own code scrolls). In the handler, recompute whether the reader is at the bottom and keep it in a ref, so the stream loop can read the latest value without re-rendering. Also set a state flag that shows or hides the Jump button. - 5
Scroll before paint. Do the scrolling in
useLayoutEffect, which runs after React has updated the page and before the browser draws it. The reader never sees a frame where the new text is below the fold. A normaluseEffectruns after paint and can flicker. - 6
Scroll anchoring. Browsers keep the content you are looking at steady when something above it changes height, for example an image above it finishes loading or an earlier message is redrawn. This is called scroll anchoring (CSS
overflow-anchor, on by default; MDN lists it as available in all current browsers). It works for you, so do not fight it. Text appended below a reader who scrolled up never moves what they are reading anyway: your own rule only has to avoid scrolling them. - 7
Mobile keyboards. On a phone, the on-screen keyboard covers part of the page. By default the keyboard shrinks only the *visual viewport* (the part the reader can see), not the layout viewport that CSS sizes like
100vhand100dvhfollow. So a chat sized with100dvhcan still have its input hidden behind the keyboard.100dvh(dynamic viewport height) solves a different problem: it follows the browser's own toolbars as they slide in and out while scrolling, which100vhdoes not. For the keyboard, either addinteractive-widget=resizes-contentto the viewport<meta>tag, which makes the layout shrink with the keyboard (check MDN's support table for the browsers you ship to), or listen to thevisualViewportresizeevent and size the chat tovisualViewport.height. Test on a real phone with the keyboard open, and scroll to the bottom again when the viewport resizes if the reader was following. - 8
Edge cases. A short answer that fits without scrolling counts as at the bottom. An empty list too. A reader who scrolls up and then back down to the bottom should resume following, which the same recomputation gives for free. When the reader presses Jump to latest, scroll and hide the button; the next scroll event confirms it.
Explain it without notes
State the rule for auto-scroll while an answer streams.
Why use a threshold instead of checking equality with the bottom?
Why scroll in useLayoutEffect?
How do phone keyboards affect a chat layout?
Practice
Add a new-messages counter to the Jump button: count how many pieces of text arrived while the reader was away.
Write isAtBottom so that it also handles an element with no overflow (nothing to scroll).
Trade-offs
- ↔
Following only at the bottom needs a ref, a handler and a button, and respects the reader. Always-scroll is one line and annoys anyone who scrolls up.
- ↔
Smooth scrolling looks polished but lags behind fast streams. Instant scrolling during streaming is more reliable.
Done when you can
I can compute distance from the bottom and apply a threshold.
I keep the follow decision in a ref and the button flag in state.
I scroll in useLayoutEffect and offer Jump to latest.
I can explain scroll anchoring and 100dvh.
I log decisions, not pixel positions, in tests.