Command Palette

Search for a command to run...

Hectal
PHASE 2Beginner ~24 min· topic 7 of 8

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 resize event.
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.

the three pieceswhole filejsx
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.

Pixels are not for testsdiagram
Rendering diagram…

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. 1

    Raise the threshold

    In the pure function example change threshold = 24 to 300. The 200 px scrolled-up reader now counts as at the bottom, and the line reader scrolled up 200 px: says follow instead of leave alone. A threshold larger than a screenful makes scrolling up impossible while text streams.

  2. 2

    Scroll on the wrong effect

    In the first example change useLayoutEffect to useEffect and 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. 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

Follow the text, let the reader leave, jump back

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

Press Run to see the page.

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: true
The decision as a pure function, and what this browser supports

The 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

Press Run to see the page.

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): object

Break 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).

terminal
$ The console
── what you'll see ──
reader scrolled up, jump button shown: false
more text arrived, the view stayed where the reader put it: false | box has more text than fits: true
Uncaught TypeError: Cannot read properties of null (reading 'click')

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. 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. 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) and clientHeight (visible height). The distance from the bottom is scrollHeight - scrollTop - clientHeight.

  3. 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. 4

    Remember the decision in a ref, update it on scroll. A scroll event 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. 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 normal useEffect runs after paint and can flicker.

  6. 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. 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 100vh and 100dvh follow. So a chat sized with 100dvh can 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, which 100vh does not. For the keyboard, either add interactive-widget=resizes-content to 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 the visualViewport resize event and size the chat to visualViewport.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. 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

01

State the rule for auto-scroll while an answer streams.

02

Why use a threshold instead of checking equality with the bottom?

03

Why scroll in useLayoutEffect?

04

How do phone keyboards affect a chat layout?

Practice

01

Add a new-messages counter to the Jump button: count how many pieces of text arrived while the reader was away.

02

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.