# Scroll area

> A focusable scrolling region with thin native scrollbars, and edge shadows where scroll-driven animations are supported.

Page: https://svelte-lean.vercel.app/primitives/scroll-area · Markdown: https://svelte-lean.vercel.app/primitives/scroll-area.md · Index: https://svelte-lean.vercel.app/llms.txt

A scrolling region with thin styled scrollbars and edge shadows that a keyboard user can reach, for content taller or wider than its box.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/scroll-area.css';
</script>

<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
	role="region"
	aria-label="Release notes"
	tabindex="0"
	data-slean="scroll-area"
	data-variant="outlined"
	class="notes"
>
	<h3>2.4</h3>
	<p>Offline sync for notes; conflicts are shown side by side.</p>
	<h3>2.3</h3>
	<p>Shared folders with read-only members.</p>
	<h3>2.2</h3>
	<p>Search across attachments, including scanned pages.</p>
	<h3>2.1</h3>
	<p>Keyboard shortcuts for every command in the editor.</p>
	<h3>2.0</h3>
	<p>A new editor with tables, checklists and code blocks.</p>
	<h3>1.9</h3>
	<p>Exports to Markdown and HTML.</p>
	<h3>1.8</h3>
	<p>Reminders on notes, with a daily digest.</p>
</div>

<style>
	/* The page gives the area its size. */
	.notes {
		max-block-size: 14rem;
		max-inline-size: 28rem;
		padding: var(--slean-space-4);
	}

	.notes h3 {
		margin: var(--slean-space-4) 0 var(--slean-space-1);
		font-size: var(--slean-text-md);
	}

	.notes h3:first-child {
		margin-block-start: 0;
	}

	.notes p {
		margin: 0;
	}
</style>
```

Tab to the area and use the arrow keys, PageDown or End. The edge shadows appear where scroll-driven animations are supported.

## Examples

### Horizontal

```svelte
<script lang="ts">
	import '@svelte-lean/styles/scroll-area.css';
</script>

<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
	role="region"
	aria-label="Recent projects"
	tabindex="0"
	data-slean="scroll-area"
	data-slean-orientation="horizontal"
>
	<ul class="row">
		<li><strong>Harbor</strong> Shipping dashboard</li>
		<li><strong>Lighthouse</strong> Status page</li>
		<li><strong>Breakwater</strong> Rate limiter</li>
		<li><strong>Tidewater</strong> Data pipeline</li>
		<li><strong>Mooring</strong> Feature flags</li>
		<li><strong>Quayside</strong> Billing portal</li>
	</ul>
</div>

<style>
	.row {
		display: flex;
		gap: var(--slean-space-3);
		margin: 0;
		padding: var(--slean-space-3);
		list-style: none;
	}

	.row li {
		display: grid;
		gap: var(--slean-space-1);
		flex: none;
		inline-size: 11rem;
		padding: var(--slean-space-6) var(--slean-space-4);
		border: 1px solid var(--slean-border);
		border-radius: var(--slean-radius-md);
		font-size: var(--slean-text-sm);
	}
</style>
```

A vertical wheel over the row still scrolls the page; the edge shadows move to the sides.

### Wide table

```svelte
<script lang="ts">
	import '@svelte-lean/styles/scroll-area.css';
</script>

<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
	role="region"
	aria-labelledby="scroll-invoices-caption"
	tabindex="0"
	data-slean="scroll-area"
	data-slean-orientation="horizontal"
	data-variant="outlined"
>
	<table class="invoices">
		<caption id="scroll-invoices-caption">Invoices</caption>
		<thead>
			<tr><th>Number</th><th>Customer</th><th>Status</th><th>Due</th><th>Amount</th><th>Currency</th></tr>
		</thead>
		<tbody>
			<tr><td>INV-1041</td><td>Northwind Traders</td><td>Paid</td><td>12 Sep 2026</td><td>1,280.00</td><td>EUR</td></tr>
			<tr><td>INV-1042</td><td>Contoso Ltd</td><td>Pending</td><td>18 Sep 2026</td><td>940.50</td><td>USD</td></tr>
			<tr><td>INV-1043</td><td>Fabrikam Inc</td><td>Overdue</td><td>21 Sep 2026</td><td>3,100.00</td><td>USD</td></tr>
			<tr><td>INV-1044</td><td>Tailspin Toys</td><td>Paid</td><td>27 Sep 2026</td><td>415.75</td><td>GBP</td></tr>
		</tbody>
	</table>
</div>

<style>
	.invoices {
		min-inline-size: 44rem;
		border-collapse: collapse;
		font-size: var(--slean-text-sm);
	}

	.invoices caption {
		padding: var(--slean-space-3) var(--slean-space-4) 0;
		font-weight: var(--slean-font-weight-medium);
		text-align: start;
	}

	.invoices :is(th, td) {
		padding: var(--slean-space-2) var(--slean-space-4);
		text-align: start;
		white-space: nowrap;
	}
</style>
```

### Many small areas

```svelte
<script lang="ts">
	import '@svelte-lean/styles/scroll-area.css';

	const lists = [
		{ id: 'scroll-members', title: 'Members', items: ['Amara Okafor', 'Daniel Kim', 'Elif Yıldız', 'Hugo Martin', 'Ines Costa', 'Kai Nakamura', 'Lena Vogel', 'Mateo Ruiz'] },
		{ id: 'scroll-channels', title: 'Channels', items: ['#general', '#design', '#engineering', '#releases', '#support', '#random', '#hiring', '#events'] }
	];
</script>

<!-- Many small areas: role="group" names each one without adding a landmark. -->
<div class="lists">
	{#each lists as list (list.id)}
		<div>
			<p id={list.id}>{list.title}</p>
			<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
			<div
				role="group"
				aria-labelledby={list.id}
				tabindex="0"
				data-slean="scroll-area"
				data-variant="outlined"
			>
				<ul>
					{#each list.items as item (item)}
						<li>{item}</li>
					{/each}
				</ul>
			</div>
		</div>
	{/each}
</div>

<style>
	.lists {
		display: grid;
		grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
		gap: var(--slean-space-4);
		max-inline-size: 28rem;
		font-size: var(--slean-text-sm);
	}

	.lists p {
		margin: 0 0 var(--slean-space-1);
		font-weight: var(--slean-font-weight-medium);
	}

	.lists [data-slean='scroll-area'] {
		max-block-size: 8rem;
	}

	.lists ul {
		margin: 0;
		padding: var(--slean-space-2) var(--slean-space-3);
		list-style: none;
		line-height: 1.8;
	}
</style>
```

role="group" names each area without adding a landmark per list.

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/scroll-area.css';
```

Give the region its size from the page: a `max-block-size` for a vertical area, content wider than the region for a horizontal one (`data-slean-orientation="horizontal"`). Name it with `aria-label`, or `aria-labelledby` pointing at a visible title, and keep `tabindex="0"`.

Use `role="group"` instead of `region` when a page has many small scroll areas, since each named region is a landmark. `data-variant="outlined"` adds a border and the surface color; content with its own opaque background covers the shadows.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<div role="region" aria-label="…" tabindex="0" data-slean="scroll-area">` | – | yes | data-slean-orientation="vertical\|horizontal"; data-variant="outlined". The page sets its block size or max. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab` | anywhere | Reaches the region (tabindex="0") |
| `ArrowDown`/`ArrowUp` | focus on the region | Scrolls by a line (native) |
| `ArrowRight`/`ArrowLeft` | focus on the region | Scrolls sideways by a line (native) |
| `PageDown`/`PageUp`/`Space` | focus on the region | Scrolls by a page (native) |
| `Home`/`End` | focus on the region | Scrolls to the start or the end (native) |

## Styling

`scroll-area.css` sets the scrollbar width, colors and gutter, the overscroll containment per axis, the outline variant and the scroll-driven edge shadows. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-border-strong` | `var(--slean-neutral-8)` | scrollbar-color |
| `--slean-radius-md` | `var(--slean-radius)` | border-radius |
| `--slean-border` | `var(--slean-neutral-6)` | border |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-overlay` | `oklch(0% 0 0 / 0.4)` | box-shadow |

State selectors the stylesheet targets, all from the platform or ARIA: `:dir(rtl)`.

Variant attributes: `data-slean-orientation` (`horizontal`); `data-variant` (`outlined`).

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `overflow: auto`, `tabindex="0"`, `scrollbar-width`, `scrollbar-color`, `scrollbar-gutter`, `overscroll-behavior`, `animation-timeline: scroll()`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `overflow: auto + tabindex="0" in role="region"`

Tier 0: the scroll area has no behavior module, the Vite plugin maps `scroll-area` to no module, and the page ships no Svelte Lean JavaScript for it.

Why this implementation

Every scrolling region is already a native component: `overflow: auto` scrolls, the browser draws the scrollbars, and the arrow keys, PageDown, Space, Home and End scroll a focused region. What a region with only text inside lacks is a way to receive focus in every browser, and a name for the screen reader that lands on it. `tabindex="0"` and `role="region"` with `aria-label` give it both.

Libraries replace the scrollbar with elements of their own to style it; the platform now styles the real one. `scroll-area.css` sets `scrollbar-width: thin`, `scrollbar-color` from the tokens and `scrollbar-gutter: stable`, contains overscroll on the scrolling axis, and, where scroll-driven animations exist, drives inset shadows from the region’s own scroll timeline.

### The browser owns

- scrolling, the scrollbars and their dragging
- the scrolling keys: arrows, PageUp, PageDown, Space, Home, End
- the tab stop from tabindex="0" and the announcement of the named region
- scroll chaining and its containment, the scroll timeline behind the shadows

### Svelte Lean owns

- scroll-area.css: thin token-colored scrollbars, a stable gutter, overscroll contained on one axis
- the edge shadows under @supports, mirrored under RTL
- the contract: the name, the role and the tab stop a scrolling region needs
- documentation

Accessibility

- `tabindex="0"` makes the region a tab stop in every browser, so a keyboard user can scroll it when it holds nothing focusable.
- `role="region"` (or `group`) with a name: a focusable element needs one, and it is announced when focus lands on the region.
- The scrolling keys are the browser’s: arrows, PageUp, PageDown, Space, Home and End.
- The scrollbars are the platform’s, so their pointer behavior, their size in the system settings and forced colors are unchanged.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `scrollbar-width` | Baseline 2024 (Safari 18.2) | Safari before 18.2 draws its default scrollbar |
| `scrollbar-color` | Baseline 2025 (Safari 26.2) | Safari before 26.2 keeps the system colors |
| `scrollbar-gutter` | Baseline 2024 | The content shifts when the scrollbar appears |
| `overscroll-behavior: contain` | Not Baseline (Chrome 63, Firefox 59, Safari 16 on scrolling containers) | The page scrolls on when the area reaches its end |
| `Scroll-driven animations` | Not Baseline (Chrome 115, Safari 26) | No edge shadows; the area scrolls the same |

Without JavaScript

Fully functional: scrolling, the keyboard and the scrollbars are native.

Server rendering and hydration

Static markup. The region starts at the beginning of its content.

Nothing is attached; the area scrolls before and after hydration alike.

Compatibility

`scrollbar-width` and `scrollbar-gutter` are Baseline 2024 (Safari 18.2) and `scrollbar-color` Baseline 2025 (Safari 26.2); older Safari keeps its default scrollbar. `overscroll-behavior` works on scrolling containers in every current engine but is not listed as Baseline, because engines differ on containers without overflow. Scroll-driven animations are not Baseline (Chrome 115, Safari 26, not Firefox): without them there are no edge shadows.

Tests and source

- `apps/playground/tests/primitives/display-extra.spec.ts` Playwright, with page JavaScript enabled and disabled: roles, names, keyboard, geometry, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/scroll-area/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/scroll-area/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/scroll-area.css` the optional stylesheet
