Follow scroll
A scroller that stays at the end while new content arrives - and stops the moment the reader scrolls away.
pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-follow-scroll.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-follow-scroll.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-follow-scroll.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-follow-scroll.jsonLogs, build output, chat threads: anything that grows from the bottom needs the same rule, and scrollTop = scrollHeight on every update is not it. That version yanks the view back down while
someone is reading further up, which is the single most complained-about behaviour in a log viewer.
The fix people reach for - a "follow" checkbox - moves the decision onto the reader for something
they already expressed by scrolling.
- Queued
- Starting runner
<script lang="ts">
import { onDestroy } from 'svelte';
import FollowScroll from '#lib/components/coral/kit/follow-scroll/follow-scroll.svelte';
import { Button } from '#lib/components/ui/button/index.js';
const steps = [
'Restoring cache',
'Installing dependencies',
'Running tests',
'Type-checking',
'Building',
'Uploading artefacts'
];
let lines = $state<string[]>(['Queued', 'Starting runner']);
let pinned = $state(true);
let timer: ReturnType<typeof setInterval> | undefined;
function start() {
clearInterval(timer);
lines = ['Queued', 'Starting runner'];
let step = 0;
timer = setInterval(() => {
lines = [...lines, `${steps[step % steps.length]} (${lines.length})`];
step++;
if (lines.length > 60) clearInterval(timer);
}, 220);
}
onDestroy(() => clearInterval(timer));
</script>
<div class="flex w-full max-w-xl flex-col gap-3">
<div class="flex items-center gap-3">
<Button variant="outline" size="sm" onclick={start}>Run the build</Button>
<span class="text-sm text-muted-foreground">
Following: {pinned ? 'yes' : 'no - scroll back down to resume'}
</span>
</div>
<FollowScroll
bind:pinned
count={lines.length}
class="h-56 rounded-lg border"
aria-label="Build log"
>
<ol class="p-3 font-mono text-xs">
{#each lines as line, index (index)}
<li class="py-0.5 text-muted-foreground">{line}</li>
{/each}
</ol>
</FollowScroll>
</div>What Coral adds
- It follows only while the reader is at the end. Scroll back and it lets go; scroll down again and it picks up where it left off. Nothing to tick.
- "At the end" has a tolerance. Fractional scroll heights on a zoomed page never land on an exact equality, a smooth scroll stops a pixel short, and a single nudge of the wheel is not a decision to stop following.
- It says what you missed. While the reader is behind, a control offers the way back - with a count of what arrived, when there is something countable to count.
- Growth is watched, not guessed. A
ResizeObserveron the content catches text streaming into an existing line as well as new lines arriving. - Reduced motion is respected. Following is instant either way; the jump back is smooth unless the reader asked for less movement.
With a composer
- Amara Diallo
Amara Diallo
The staging deploy is green again.
- Wei Zhang
Wei Zhang
Nice. Was it the lockfile?
- Amara Diallo
Amara Diallo
Yes - regenerated it and the build step passed.
- Priya Sharma
Priya Sharma
I will roll it to production after the review.
- Kenji Tanaka
Kenji Tanaka
Adding the changelog entry now.
<script lang="ts">
import Textarea from '#lib/components/coral/kit/textarea/textarea.svelte';
import FollowScroll from '#lib/components/coral/kit/follow-scroll/follow-scroll.svelte';
import Avatar from '#lib/components/coral/kit/avatar/avatar.svelte';
type Message = { id: number; from: string; body: string };
let messages = $state<Message[]>([
{ id: 1, from: 'Amara Diallo', body: 'The staging deploy is green again.' },
{ id: 2, from: 'Wei Zhang', body: 'Nice. Was it the lockfile?' },
{ id: 3, from: 'Amara Diallo', body: 'Yes - regenerated it and the build step passed.' },
{ id: 4, from: 'Priya Sharma', body: 'I will roll it to production after the review.' },
{ id: 5, from: 'Kenji Tanaka', body: 'Adding the changelog entry now.' }
]);
let draft = $state('');
function send(text: string) {
messages = [...messages, { id: messages.length + 1, from: 'You', body: text.trim() }];
draft = '';
}
</script>
<div class="flex w-full max-w-md flex-col gap-2">
<FollowScroll count={messages.length} class="h-52 rounded-lg border" aria-label="Thread">
<ul class="flex flex-col gap-3 p-3">
{#each messages as message (message.id)}
<li class="flex items-start gap-2">
<Avatar name={message.from} size="sm" />
<div class="min-w-0">
<p class="text-xs text-muted-foreground">{message.from}</p>
<p class="text-sm">{message.body}</p>
</div>
</li>
{/each}
</ul>
</FollowScroll>
<Textarea
bind:value={draft}
rows={1}
maxRows={4}
submitOn="enter"
onsubmit={send}
placeholder="Message the thread"
aria-label="Message the thread"
/>
</div>pinned is bindable and reads as an instruction, not just a flag: set it to true after sending a
message and the view returns to the end. Start it at false for a view that opens at the top.
Import
<script lang="ts">
import FollowScroll from '#lib/components/coral/kit/follow-scroll/follow-scroll.svelte';
</script>Props
Everything a <div> accepts stays available on the scroller. On top of that:
| Prop | Type | Default | Description |
|---|---|---|---|
pinned | boolean | true | Bindable. Whether it is following the end. |
onpinnedchange | (pinned: boolean) => void | - | The reader pinned or unpinned it. |
count | number | - | How many entries there are, so what arrived can be counted. |
threshold | number | 32 | How close to the end still counts as the end, in pixels. |
jumpLabel | string | Jump to latest | Label for the way back. |
unreadLabel | (unread: number) => string | 3 new | Reads the count on it. |
class | string | - | Merged onto the outer element. Give it a height. |
children | Snippet | - | The content. Required. |
jump | Snippet<[{ unread, behind, scrollToEnd }]> | - | Replaces the way back. |
count is optional on purpose: the view follows content by height, so a stream of tokens with no
countable entries works exactly the same - it just says "jump to latest" without a number.
Accessibility
The scroller is a real scrolling element, so the keyboard reaches it and Page Down, Home and End do
what they always do. Give it an aria-label, as the demos do, because a scrollable region that
takes focus should say what it holds.
New content is not announced. A log that reads itself out loud over whatever the reader is doing is
unusable; where an arrival is worth announcing, announce the one that matters with lib/live-region rather than the stream.
What is deliberately not here
Keeping the position when content is added above - loading older messages - which is the opposite problem and wants anchoring rather than following. And virtualization: this renders what it is given, and a list long enough to need windowing needs it whether or not it follows.
follow.ts
The rules are plain functions over a viewport, so the same decision can drive something else:
import { isAtEnd, unreadSince } from '#lib/components/coral/kit/follow-scroll/follow.js';
isAtEnd({ scrollTop: 590, clientHeight: 400, scrollHeight: 1000 }); // true - within the tolerance
unreadSince(12, 5); // 7