# ScrollAnchor

> A scroll region that stays pinned to its last line while content streams in, lets go when the reader scrolls up, and floats a pill offering the way back down.

- Package: `fancy-ui-svelte` (Svelte 5 + Tailwind CSS v4)
- Category: AI Chat
- Tags: ai, chat, scroll, streaming, utility
- Docs: https://fancy-ui.rama.app/docs/components/scroll-anchor
- Source: https://github.com/RamaHerbin/fancy-ui/tree/main/src/lib/fancy-ui/scroll-anchor

## Import

```svelte
<script lang="ts">
  import { ScrollAnchor } from 'fancy-ui-svelte';
</script>
```

Setup (once per project): `npm install fancy-ui-svelte`, then add `@import "fancy-ui-svelte/tailwind.css";` after `@import "tailwindcss";` in your main CSS file. Full guide: https://fancy-ui.rama.app/llms.txt

React: `import { ScrollAnchor } from 'fancy-ui-react';` (same prop names; `class` becomes `className`, snippets become `ReactNode`).

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `active` | `boolean` | `true` | Whether the region pins itself to the bottom as content arrives — bind it to your streaming flag. False leaves an ordinary scroll box, and hides the return button with it |
| `bottomThreshold` | `number` | `40` | How close to the bottom (px) still counts as pinned |
| `returnLabel` | `string` | `"Jump to latest"` | Label on the floating return button, and its accessible name |
| `showReturn` | `boolean` | `true` | Whether the floating return button appears once the reader scrolls away from the bottom |
| `maxHeight` | `string` | `"100%"` | Height cap on the scrolling region — any CSS length |
| `onStickChange` | `(stuck: boolean) => void` | — | Called when the region pins itself or lets go, never on every scroll |
| `sound` | `boolean` | `false` | Plays press on the return-to-latest pill, once the user has enabled sound |

## Snippets

| Snippet | Description |
| --- | --- |
| `children` | The scrolling content |

## Examples

### Basic Usage

```svelte
<script lang="ts">
	import { onMount } from "svelte";
	import { ScrollAnchor } from "fancy-ui-svelte";

	/** How long each line waits before the next one lands. */
	const TICK_MS = 800;
	/** How long the finished transcript sits there before the run starts over. */
	const HOLD_MS = 3200;

	const LINES = [
		"Reading the billing schema…",
		"`invoices` has a `status` column, four values.",
		"Only `open` and `past_due` count as owed.",
		"Joining `customers` to get a name per row.",
		"Two customers have no row in `customers` — left join, then.",
		"Grouping by customer, summing `amount_due`.",
		"Ordering by the total, largest first.",
		"Capping at twenty so the answer stays readable.",
		"Running it against the read replica…",
		"18 rows. Two of them are the same customer under different ids.",
		"Folding those together on `email`.",
		"17 rows. That's the answer.",
	];

	let shown = $state(1);
	let streaming = $state(true);

	onMount(() => {
		// A transcript that never stops arriving is exactly what reduced motion is
		// asking us not to run, so it gets the finished thing and no timer at all.
		if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
			shown = LINES.length;
			streaming = false;
			return;
		}

		let timer: ReturnType<typeof setTimeout>;

		function step() {
			if (shown < LINES.length) {
				shown += 1;
				timer = setTimeout(step, TICK_MS);
				return;
			}
			// Start over rather than stop: a docs page left open should still have
			// something to scroll away from an hour later.
			timer = setTimeout(() => {
				shown = 1;
				timer = setTimeout(step, TICK_MS);
			}, HOLD_MS);
		}

		timer = setTimeout(step, TICK_MS);
		return () => clearTimeout(timer);
	});
</script>

<div class="flex w-full max-w-lg flex-col gap-3 p-6">
	<ScrollAnchor
		sound
		active={streaming}
		maxHeight="13rem"
		class="border-border bg-card/50 rounded-lg border"
	>
		<div class="flex flex-col gap-2 p-4 font-mono text-xs leading-relaxed">
			{#each LINES.slice(0, shown) as line, i (`${line}#${i}`)}
				<p class="text-muted-foreground">{line}</p>
			{/each}
		</div>
	</ScrollAnchor>

	<p class="text-muted-foreground text-xs">
		The region stays pinned to the last line while the transcript grows. Scroll up mid-stream and it
		lets go — a “Jump to latest” pill appears, and pressing it hands the region back to the stream.
	</p>
</div>
```
