registry:ui · scroll-area family
scroll-area — the scrollable region, always hand-drawn
A dedicated scrollable-region component after shadcnui, with the jixoai law: the component IS a native scroll container — wheel, touch momentum, keyboard and scroll-snap stay platform behavior — and the scrollbar is ALWAYS HAND-DRAWN (the 2026-09-15 rework retired the dual-mode scrollbar prop; no mode branch exists, breaking). Capsule thumb on the scrollbar-token law, idle fade with four testable auto-hide pins, hover growth, drag pinning, keyboard affordances on region and thumb. The platform path is its own sibling (native-scroll-area); both share the scroll-area-kit kernel. The windowed-list sibling (scroll-virtual) lives on its own page, and the ToC metadata export lets a table of contents derive itself from your content.
$npx jixoai-ui add scroll-area
https://ui.jixoai.com/r/scroll-area.json
overview
Overview
What this family adds beyond the platform: the always-hand-drawn capsule — four testable auto-hide pins, a thumb with its own scrollbar contract, and chrome params — over the shared scroll-area-kit core.
The component IS a native scroll container — wheel, touch momentum, keyboard, scroll-snap and overscroll chaining stay platform behavior — and the scrollbar is ALWAYS HAND-DRAWN: the 2026-09-15 rework retired the dual-mode scrollbar prop entirely (no mode branch exists; breaking). What the family ADDS beyond the platform path is the drawn chrome and its behavior: a capsule-or-square thumb on the scrollbar-token law (currentColor family — themes and dark stages restyle it without a line of JS), the ~700ms idle fade with FOUR separately probe-asserted pins (region focus-within, thumb focus, active drag, hover — measured: live at 1.1s focused, faded by 1.75s after blur), edge-anchored hover growth, drag pinning, a keyboard-draggable thumb, and track-click paging. The chrome params are geometry you own: width tiers (thin 8 / auto 12 / wide 16px) and the radius prop (square-cut 0 by default; px; the 'full' capsule).
The kit-shaped sharing law applies verbatim: the scroll-area-kit lib item splits by concern so the siblings share the CORE (overflow verdict, thumb geometry math, the RTL inline-engine funnel, theme-scope resolution — zero paint, zero ARIA) and never each other's halves. THIS family consumes the hand-drawn INTERACTION ADAPTER (the pins, the fade, the thumb's a11y contract mounted on bare nodes); the native sibling consumes the CAPABILITY STYLES and drives its scheme observer from the kit's resolveThemeScope. The floors are environmental, not modes: content that fits draws nothing (the none verdict gates the chrome — four fitted regions visible on this very page), coarse pointers keep the platform's momentum bars, no-JS output shows the platform bar until hydration upgrades it, and prefers-reduced-motion keeps the chrome statically visible.
The eight axes are forwarders (all no-own — the family manufactures no axis opinion):
the chrome paints currentColor, so color arrives through inheritance rather than a
carrier; the radius NAME belongs to the thumb's chrome param (the one deliberate
collision, with the px number lane double-stamping as the universal radius anchor);
motion arrives as a floor — reduced-motion keeps the chrome statically visible. Measured
in the axes table below. Kinship: native-scroll-area (the platform path — its own
registry item), scroll-virtual (the windowed-list sibling), toc-outline (the metadata consumer of
getViewport()).
scroll-area
the component IS a native scroll container — wheel, touch momentum, keyboard and scroll-snap stay platform behavior; the chrome is the hand-drawn capsule on the token law.
<ScrollArea
label="config demo"
orientation="vertical"
pad="0.75rem"
class="h-40"
>
…scrolling content…
</ScrollArea>the hand-drawn law
capsule — the complete style-control surface
The thumb is a full-radius capsule painted by the scrollbar-token law
(currentColor family) — themes and dark stages restyle
it without a line of JS. It auto-hides after ~700ms idle, and FOUR pins suspend the
fade, each separately probe-asserted: region focus-within, thumb focus, active drag,
hover. While any pin holds, the thumb stays in the accessibility tree with its role="scrollbar" intact and aria-valuenow tracking position. Hover widens +
brightens; drag pins the active tone; the thumb is focusable and keyboard-draggable
(arrows step, PageUp/PageDown page, Home/End jump); track-click pages. Content that
fits draws nothing; touch keeps the platform's own momentum bars; reduced motion
keeps the chrome statically visible.
scroll-area · capsule
<script lang="ts">
import ScrollArea from '@ui/scroll-area.svelte';
</script>
<ScrollArea class="h-56" label="capsule demo">
<div class="jx-log">
{#each Array(60) as _, i (i)}
<p class="jx-log-line">log line {i} — capsule thumb, idle fade, the four pins</p>
{/each}
</div>
</ScrollArea>chrome parameters
radius × width — the configuration matrix
The chrome's geometry is parameterized (Owner r2): the thumb is square-cut by default — radius paints any
px or the retired-to-opt-in 'full' capsule — and width tiers the lane (thin 8px · auto 12px · wide 16px, the thumb resting 6/10/14 — track − 2, the 2px living on the START side — and growing to the full track 8/12/16 under hover/drag). The track runs
FLUSH to the region edge and the growth is EDGE-ANCHORED: the edge-side flank sits AT the region edge and never moves while
the thumb widens strictly into the content. Every cell below scrolls on both axes —
hover a thumb in any tier to feel the anchor.
scroll-area · radius × width
<ScrollArea radius={6} width="wide" orientation="both" class="h-36" label="matrix cell">
<!-- radius: a px number | 'full' | omitted → square-cut (0, the default)
width: 'thin' 8px | 'auto' 12px (default) | 'wide' 16px lane -->
…content overflowing both axes…
</ScrollArea>family sibling
native-scroll-area — the platform path, its own item
The 2026-09-15 rework did NOT split native vs non-native inside one component: the platform scrollbar ships as its own registry item under the same token law, with the native best practices packaged as capability styles (stable gutter, stage-scoped color-scheme, width tiers, overscroll containment) — and NO custom scrollbar ARIA: the platform bar IS the accessibility contract there. It lives on /docs/components/native-scroll-area.html.
registry:lib · scroll-area-kit
scroll-area-kit — one kernel, split by concern
The family's shared lib item (the control-chrome precedent): behavior lives in the kit; paint lives in the consumer.
THREE parts, so a consumer adopts exactly what it is: (a) the shared CORE (core.ts) — the overflow verdict (the scroll-run
verdict's shape, one per axis pair), thumb geometry math, the RTL inline-engine
funnel, and theme-scope resolution; zero paint, zero ARIA. (b) the HAND-DRAWN INTERACTION ADAPTER (hand-drawn.svelte.ts, consumed ONLY by scroll-area) —
the idle fade, the four pins, drag pinning, keyboard scrolling, track paging, and the
thumb's a11y contract mounted on consumer-rendered bare nodes. (c) the NATIVE CAPABILITY STYLES (native-capability.css, consumed ONLY by
native-scroll-area) — the packaged platform best practices. The siblings share the
core while touching disjoint kit parts.
family sibling
scroll-virtual — moved to its own page
The TanStack-backed windowed list is a registry item of its own now — the 100,000-row demo, the count playground and the imperative passthroughs live on /docs/components/scroll-virtual.html.
toc metadata export
The scrollable area exports what the ToC needs: the viewport element (scrollRoot), and the outline its content derives — heading-to-heading extents, no data-region markup. The page rail on the right already runs in outline mode; this demo pairs an inner scroller with the engine directly.
/**
* jixoai ToC outline derivation (registry/files/lib/toc-outline.ts).
*
* 2026-08-22 · Scroll-area family, request 3 (Owner): "ToC 所需的元数据导
* 出" — the framework-free half of automatic ToC linkage. Today every page
* hand-writes the same id 3–4 times (tocSections literal + wrapper id +
* data-region/data-family) and every label twice. This lib derives the
* outline FROM the content itself: scan heading levels, slugify labels,
* stamp the ids back (fragment links need a real id), and expose each
* entry's EXTENT (heading → next same-or-higher heading) for the geometry
* engine's extents provider — no data-region markup, no DOM restructuring
* (moving nodes at runtime would break Svelte ownership; extents are the
* sanctioned route).
*
* Pairs with: toc-engine's `extents` option (weights/pick over derived
* extents) and toc.svelte's `outline` prop (zero-handwritten-id ToC).
* Framework-free by the lib law: DOM in, plain data out, zero listeners.
*/
export interface TocOutlineEntry {
/** fragment id — the heading's existing id, or the slugified label
* (stamped back onto the heading so `href="#id"` resolves natively) */
id: string;
/** trimmed heading text */
label: string;
/** heading level (2 for h2, 3 for h3 …) */
level: number;
/** the heading element — the region START */
start: Element;
/** the next heading of level <= this entry's level: the region END
* boundary (exclusive). null for the last entry (extent = to content
* end — the engine saturates it against the viewport). */
end: Element | null;
}
export interface TocOutlineOptions {
/** heading levels that join the outline, default [2, 3].
* Values outside 1–6 are dropped; the minimum present level becomes the
* section tier and everything deeper collapses into child entries. */
levels?: readonly number[];
}
/**
* Derive the outline from `root`'s heading tree. DOM order is the outline
* order (querySelectorAll is document order). Headings inside an element
* carrying data-toc-skip (or the heading itself) are skipped. Idempotent:
* derived slugs stamp onto the headings, so re-derivation (content
* mutation, HMR) yields the same ids.
*/
export function deriveTocOutline(root: ParentNode, options: TocOutlineOptions = {}): TocOutlineEntry[] {
const levels = [...new Set((options.levels ?? [2, 3]).filter((l) => l >= 1 && l <= 6))].sort(
(a, b) => a - b,
);
if (levels.length === 0) return [];
const selector = levels.map((l) => `h${l}`).join(',');
const used = new Set<string>();
const headings = Array.from(root.querySelectorAll<HTMLElement>(selector)).filter(
(el) => !el.closest('[data-toc-skip]'),
);
const baseSlugOf = (label: string, index: number): string => {
const slug = label
.toLowerCase()
// ascii-slugs only: CJK and friends collapse to nothing, so keep a
// positional fallback (stable across re-derivation for static content)
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return slug || `section-${index + 1}`;
};
const slugOf = (label: string, index: number): string => {
const base = baseSlugOf(label, index);
let id = base;
let n = 2;
while (used.has(id)) id = `${base}-${n++}`;
used.add(id);
return id;
};
const entries: TocOutlineEntry[] = [];
headings.forEach((el, i) => {
const level = Number(el.tagName.slice(1));
const label = (el.textContent ?? '').trim();
// WRAPPER-TWIN GUARD (LAW #19, docs-eight-axes-mdn 2026-09-22): a
// heading whose nearest id-bearing ancestor already owns EXACTLY the
// slug this pass would mint adopts it instead of stamping — minting
// anyway twins the wrapper's id (duplicate ids: querySelector/aria
// resolution goes first-match, masking the defect; the sheet page
// receipted the fleet set). Narrower than the retired v1 ancestor
// step by construction: adoption happens ONLY on exact slug match, so
// the search corpus (which mints the same slug for the id-less
// heading) still converges — its anchor resolves to the wrapper,
// which contains this heading. The wrapper keeps its SSR-authored id.
const holder = el.closest('[id]');
let id: string;
if (el.id !== '' && !used.has(el.id)) {
id = el.id;
used.add(id);
} else if (
holder !== null &&
holder !== el &&
holder.id === baseSlugOf(label, i) &&
!used.has(holder.id)
) {
id = holder.id;
used.add(id);
// adopt — deliberately NO stamp: the wrapper owns the anchor
} else {
id = slugOf(label, i);
// stamp derived ids back: ToC links are real fragment anchors
el.id = id;
}
// extent end = next heading at same or higher level (smaller number)
let end: Element | null = null;
for (let j = i + 1; j < headings.length; j++) {
if (Number(headings[j]!.tagName.slice(1)) <= level) {
end = headings[j]!;
break;
}
}
entries.push({ id, label, level, start: el, end });
});
return entries;
}
export interface TocOutlineSection {
id: string;
label: string;
children?: { id: string; label: string }[];
}
/**
* Collapse outline entries into toc.svelte's `sections` shape: minimum
* present level = sections, deeper levels collapse into the preceding
* section's children (two tiers, the ToC surface contract).
*/
export function tocOutlineToSections(entries: readonly TocOutlineEntry[]): TocOutlineSection[] {
if (entries.length === 0) return [];
const sectionLevel = Math.min(...entries.map((e) => e.level));
const sections: TocOutlineSection[] = [];
for (const entry of entries) {
if (entry.level === sectionLevel) {
sections.push({ id: entry.id, label: entry.label });
} else {
const parent = sections[sections.length - 1];
// a deeper heading with no section above it (levels: [3] alone, or a
// stray h3 before the first h2) becomes its own section — never lost
if (!parent) sections.push({ id: entry.id, label: entry.label });
else (parent.children ??= []).push({ id: entry.id, label: entry.label });
}
}
return sections;
}
types
Types
One hand-drawn law over both axes; three orientation modes.
scroll-area · axes
<script lang="ts">
import ScrollArea from '@ui/scroll-area.svelte';
</script>
<div class="grid gap-4 min-[760px]:grid-cols-2">
<div><span>vertical</span><ScrollArea class="h-40" label="vertical sample" pad="0.75rem"><ol>{#each Array(12) as _, i (i)}<li>item {i + 1}</li>{/each}</ol></ScrollArea></div>
<div><span>horizontal</span><ScrollArea orientation="horizontal" class="h-40 w-full" label="horizontal sample"><ol class="flex gap-2">{#each Array(16) as _, i (i)}<li class="w-36 flex-none">card {i + 1}</li>{/each}</ol></ScrollArea></div>
</div>usage
Usage
Give it a height, a label, and pad for the thumb lane; the rest is a native scroll container.
<script lang="ts">
import ScrollArea from '@ui/scroll-area.svelte';
</script>
<!-- ALWAYS hand-drawn: the capsule thumb on the scrollbar-token law -->
<ScrollArea class="h-72" label="release notes" pad="0.75rem">
{#each notes as note (note.id)}
<article>…</article>
{/each}
</ScrollArea><ScrollArea orientation="horizontal" class="w-full" label="filmstrip">
<!-- both axes ride the same capsule chrome; RTL mirrors through
logical geometry -->
</ScrollArea>import { deriveTocOutline, tocOutlineToSections } from '@lib/toc-outline';
// zero handwritten ids: sections + extents derive from the heading tree
const entries = deriveTocOutline(scrollArea.getViewport());
const sections = tocOutlineToSections(entries);
// Toc component, page-level (outline mode):
// <Toc outline={{ root: '#content' }} scrollRoot={area.getViewport()} />
// engine-direct for arbitrary inner scrollers:
// createTocEngine(onUpdate, { scrollRoot: viewport, extents: () => entries });theming
Theming
The capsule look rides the scrollbar-token law: currentColor family, no JS.
| Token | Default | Source |
|---|---|---|
| --scrollbar-thumb / -hover / -active | currentColor steps | component |
| --jx-scroll-thumb-radius | 0px (square-cut) | component |
| --jx-scroll-track-w | 12px (width tiers: 8/12/16) | component |
| --jx-scroll-pad | pad prop | component |
api
API
Props from the ScrollArea Props interface (the scrollbar variant prop retired with the dual-mode era); getViewport()/scrollTo() are the imperative exports.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| orientation | 'vertical' | 'horizontal' | 'both' | 'vertical' | Which axes scroll: overflow-y/x mapping. |
| label | string | 'scrollable content' | a11y name for the region. |
| pad | string | 0 | Ring padding (CSS length), inline-axis — keeps content clear of the thumb lane. |
| width | 'auto' | 'thin' | 'wide' | 'auto' | The chrome width tier sizing the drawn lane (8/12/16px; thumb resting 6/10/14 = track − 2 with the edge side AT the region edge, hover/drag 8/12/16 = the track). 'none' is native-only vocabulary. |
| class | string | '' | Class passthrough. |
| style | string | — | Style passthrough. |
| onscroll | (event: ViewportScrollEvent) => void | — | Scroll callback from the viewport. |
| children * | Snippet | — | The scrolling content. |
| getViewport() | () => HTMLDivElement | null | export | The scrollport element — Toc scrollRoot / engine-direct linkage. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
axes
The eight axes on scroll-area
The styled sibling is a FORWARDER family: all eight axes no-own, carriers stamped for descendants, zero axis-carrier readers in the family (grep receipts). The one deliberate collision is radius — the chrome param owns the name for the THUMB (square-cut 0 default; the 'full' capsule via calc(infinity * 1px)) — and the chrome paints currentColor, so the theme story is inheritance-precise: a bare .dark class re-declares token values but only a stage that re-themes TEXT color re-themes the chrome. Motion arrives as a floor: reduced-motion keeps the chrome statically visible.
| Property | Type | Default | Description |
|---|---|---|---|
| density | '2xs' | 'xs' | 'sm' | 'default' | 'lg' | 'auto' | number (+ the five legacy spellings) | 'auto' | FORWARDER — the rung stamps data-density on the region root (measured data-density=lg explicit; null ambient) for descendants that read the kernel channels; the drawn chrome's width is the width prop's tier table (8/12/16px), not the density ladder. Number unit: coefficient. |
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | FORWARDER, ROOT ECHO — the §11 stamp scales the region root (measured 18px computed at size={18}); the drawn lane is px-tiered and does not follow it. Zero --jx-size-effective readers (grep receipt). Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | SUPPLY-ONLY — zero shape-channel readers in ui/scroll-area/ (grep receipt); the chrome corners are the radius prop’s jurisdiction. Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | THE OWNED NAME (the family's one collision ruling) — radius here is the THUMB's chrome param (px or the 'full' capsule via --jx-scroll-thumb-radius; measured 0px square-cut ambient, 6px at radius={6}, calc(infinity * 1px) at 'full' → the engine caps it to a capsule), NOT the region's concentric corner; the axis record deliberately omits the radius slot (W3-D2), and a px number lane double-stamps as the universal radius anchor for descendants (the §13 analogy, census-recorded). Number unit: px. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | FORWARDER — the chrome paints CURRENTCOLOR under the scrollbar-token law, so it follows the region's inherited text color rather than the axis carrier (zero --jx-color-effective readers, grep receipt): re-theme the stage's text color and the chrome re-themes with it (measured). Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | FORWARDER WITH A PRECISE EDGE (measured) — dark rides the .dark class bridge, but a bare .dark on the region alone re-declares token VALUES without re-theming the chrome: the thumb paints inherited currentColor, which only moves when the stage actually re-themes text color (the scrollbar-token law — 'dark stages restyle for free' means stages that restyle their text). The kit's resolveThemeScope scope-walk is the NATIVE sibling's scheme observer fuel, not this family's. system/auto = tree inheritance. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | FORWARDER — carrier stamp only; zero elevation readers (grep receipt); the chrome carries no shadow tier. Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | CONSUMED AS A FLOOR, NOT A CHANNEL — prefers-reduced-motion (checked by both the css and the kit's adapter) kills the fade transition and keeps the chrome statically visible — the adapter never even schedules the fade; --jx-motion-effective has zero readers (grep receipt). Number unit: coefficient. |
Receipts: the thumb contract digit-exact (aria-valuenow 50 at mid-travel; thumb/track height ratio 0.333 == the kit's client/scroll fraction; position 0.50 == authored), the four pins' timeline (data-thumb-live on at 1.1s focused; removed by 1.75s after blur; track opacity 1 → 0), the width tiers (8/12/16px computed), the radius ladder (0px square-cut ambient; 6px; 'full' → calc(infinity * 1px), engine-capped to a capsule), the verdict vocabulary (data-verdict-y none/start-closed/open across this page's regions; fitted regions drawing no chrome at all) and the currentColor theme edge (a bare .dark on the region changes nothing — the chrome moves when the stage re-themes text color) were measured on this page's served DOM (probe, task 38); the zero-reader rows carry grep receipts over ui/scroll-area/ + scroll-area-kit/. The query() seat below rides the md viewport key (48rem) on the size lane — the chrome params (radius/width/pad) are passthroughs and reject query(). The concentric chain stays in the family: radius=20 on the region anchors descendants (the auto Card computing max(0px, 20px − 0.875rem)).
<script lang="ts">
import ScrollArea from '@ui/scroll-area.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (13px root) applies; at 48rem+ the md case
(18px) wins — the §11 stamp scales the region root -->
<ScrollArea label="sync" size={query({ md: 18 }, 13)} class="h-40">…</ScrollArea>scroll-area · query()
Media keys are min-width: below 48rem the base (13px root) applies; at 48rem and wider the md case wins (18px) — the §11 stamp scales the region root and the content inherits it. The number lane goes bare. Resize across 48rem.
<script lang="ts">
import ScrollArea from '@ui/scroll-area.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (13px root) applies; at 48rem+ the md case
(18px) wins — the §11 stamp scales the region root -->
<ScrollArea label="sync" size={query({ md: 18 }, 13)} class="h-40">…</ScrollArea>ScrollArea · universal props
<ScrollArea size={18} density="small">…</ScrollArea>a11y
Accessibility
The WAI scrollable-region pattern, PLUS the thumb's own scrollbar contract — mounted by the kit's adapter.
Keyboard
| Key | Action |
|---|---|
| Tab | Moves focus through the scrollable area (the region, then the thumb) |
| ↑ ↓ ← → / Home / End / PgUp / PgDn | Native scrollport scrolling once the region is focused |
| arrows / PgUp / PgDn / Home / End on the thumb | Keyboard-drag the thumb itself — steps, pages, jumps (role=scrollbar contract) |
| pointer drag / track click | The thumb drags with pointer capture; a track click pages toward the click |
ARIA
| Attribute | Value | Description |
|---|---|---|
| aria-label | label prop | Accessible name for the region (default "scrollable content") |
| role | region | Plus tabindex=0 — the WAI scrollable-region pattern |
| role (thumb) | scrollbar | The thumb's contract: aria-controls → the viewport, aria-valuenow tracking 0..100, aria-orientation, focusable, keyboard-draggable |
| the four pins | focus-within / thumb focus / drag / hover | Each suspends the idle fade; while any pin holds the thumb stays in the accessibility tree ("AT-engaged" is not a detectable platform state and is deliberately not a pin) |
see-also
See also
The scroll-area family map — the styled sibling, the platform path, and the windowed list.