jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

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.

install — scroll-area

$npx jixoai-ui add scroll-area

https://ui.jixoai.com/r/scroll-area.json

nativeHTML scrollport hand-drawn capsule, always sibling: native-scroll-area shared: scroll-area-kit auto ToC outline

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.

  1. item 1 — scroll me: the capsule fades in, idles out
  2. item 2 — scroll me: the capsule fades in, idles out
  3. item 3 — scroll me: the capsule fades in, idles out
  4. item 4 — scroll me: the capsule fades in, idles out
  5. item 5 — scroll me: the capsule fades in, idles out
  6. item 6 — scroll me: the capsule fades in, idles out
  7. item 7 — scroll me: the capsule fades in, idles out
  8. item 8 — scroll me: the capsule fades in, idles out
  9. item 9 — scroll me: the capsule fades in, idles out
  10. item 10 — scroll me: the capsule fades in, idles out
  11. item 11 — scroll me: the capsule fades in, idles out
  12. item 12 — scroll me: the capsule fades in, idles out
scroll-area-usage.svelte svelte
<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

[0 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[137 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[274 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[411 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[548 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[685 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[822 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[959 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[96 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[233 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[370 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[507 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[644 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[781 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[918 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[55 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[192 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[329 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[466 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[603 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[740 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[877 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[14 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[151 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[288 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[425 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[562 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[699 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[836 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[973 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[110 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[247 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[384 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[521 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[658 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[795 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[932 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[69 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[206 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[343 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[480 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[617 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[754 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[891 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[28 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[165 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[302 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[439 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[576 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[713 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[850 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[987 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[124 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[261 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[398 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[535 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[672 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[809 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[946 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

[83 ms] capsule thumb, idle fade, four pins — not a single platform scrollbar pixel

scroll-area-capsule-demo.svelte svelte
<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

radius 0 (default)
width thin
  1. thin lane · row 1
  2. thin lane · row 2
  3. thin lane · row 3
  4. thin lane · row 4
  5. thin lane · row 5
  6. thin lane · row 6
  7. thin lane · row 7
  8. thin lane · row 8
  9. thin lane · row 9
  10. thin lane · row 10
  11. thin lane · row 11
  12. thin lane · row 12
  13. thin lane · row 13
  14. thin lane · row 14
  15. thin lane · row 15
  16. thin lane · row 16
  17. thin lane · row 17
  18. thin lane · row 18
  19. thin lane · row 19
  20. thin lane · row 20
  21. thin lane · row 21
  22. thin lane · row 22
  23. thin lane · row 23
  24. thin lane · row 24
width auto
  1. auto lane · row 1
  2. auto lane · row 2
  3. auto lane · row 3
  4. auto lane · row 4
  5. auto lane · row 5
  6. auto lane · row 6
  7. auto lane · row 7
  8. auto lane · row 8
  9. auto lane · row 9
  10. auto lane · row 10
  11. auto lane · row 11
  12. auto lane · row 12
  13. auto lane · row 13
  14. auto lane · row 14
  15. auto lane · row 15
  16. auto lane · row 16
  17. auto lane · row 17
  18. auto lane · row 18
  19. auto lane · row 19
  20. auto lane · row 20
  21. auto lane · row 21
  22. auto lane · row 22
  23. auto lane · row 23
  24. auto lane · row 24
width wide
  1. wide lane · row 1
  2. wide lane · row 2
  3. wide lane · row 3
  4. wide lane · row 4
  5. wide lane · row 5
  6. wide lane · row 6
  7. wide lane · row 7
  8. wide lane · row 8
  9. wide lane · row 9
  10. wide lane · row 10
  11. wide lane · row 11
  12. wide lane · row 12
  13. wide lane · row 13
  14. wide lane · row 14
  15. wide lane · row 15
  16. wide lane · row 16
  17. wide lane · row 17
  18. wide lane · row 18
  19. wide lane · row 19
  20. wide lane · row 20
  21. wide lane · row 21
  22. wide lane · row 22
  23. wide lane · row 23
  24. wide lane · row 24
radius 6px
width thin
  1. thin lane · row 1
  2. thin lane · row 2
  3. thin lane · row 3
  4. thin lane · row 4
  5. thin lane · row 5
  6. thin lane · row 6
  7. thin lane · row 7
  8. thin lane · row 8
  9. thin lane · row 9
  10. thin lane · row 10
  11. thin lane · row 11
  12. thin lane · row 12
  13. thin lane · row 13
  14. thin lane · row 14
  15. thin lane · row 15
  16. thin lane · row 16
  17. thin lane · row 17
  18. thin lane · row 18
  19. thin lane · row 19
  20. thin lane · row 20
  21. thin lane · row 21
  22. thin lane · row 22
  23. thin lane · row 23
  24. thin lane · row 24
width auto
  1. auto lane · row 1
  2. auto lane · row 2
  3. auto lane · row 3
  4. auto lane · row 4
  5. auto lane · row 5
  6. auto lane · row 6
  7. auto lane · row 7
  8. auto lane · row 8
  9. auto lane · row 9
  10. auto lane · row 10
  11. auto lane · row 11
  12. auto lane · row 12
  13. auto lane · row 13
  14. auto lane · row 14
  15. auto lane · row 15
  16. auto lane · row 16
  17. auto lane · row 17
  18. auto lane · row 18
  19. auto lane · row 19
  20. auto lane · row 20
  21. auto lane · row 21
  22. auto lane · row 22
  23. auto lane · row 23
  24. auto lane · row 24
width wide
  1. wide lane · row 1
  2. wide lane · row 2
  3. wide lane · row 3
  4. wide lane · row 4
  5. wide lane · row 5
  6. wide lane · row 6
  7. wide lane · row 7
  8. wide lane · row 8
  9. wide lane · row 9
  10. wide lane · row 10
  11. wide lane · row 11
  12. wide lane · row 12
  13. wide lane · row 13
  14. wide lane · row 14
  15. wide lane · row 15
  16. wide lane · row 16
  17. wide lane · row 17
  18. wide lane · row 18
  19. wide lane · row 19
  20. wide lane · row 20
  21. wide lane · row 21
  22. wide lane · row 22
  23. wide lane · row 23
  24. wide lane · row 24
radius full (capsule)
width thin
  1. thin lane · row 1
  2. thin lane · row 2
  3. thin lane · row 3
  4. thin lane · row 4
  5. thin lane · row 5
  6. thin lane · row 6
  7. thin lane · row 7
  8. thin lane · row 8
  9. thin lane · row 9
  10. thin lane · row 10
  11. thin lane · row 11
  12. thin lane · row 12
  13. thin lane · row 13
  14. thin lane · row 14
  15. thin lane · row 15
  16. thin lane · row 16
  17. thin lane · row 17
  18. thin lane · row 18
  19. thin lane · row 19
  20. thin lane · row 20
  21. thin lane · row 21
  22. thin lane · row 22
  23. thin lane · row 23
  24. thin lane · row 24
width auto
  1. auto lane · row 1
  2. auto lane · row 2
  3. auto lane · row 3
  4. auto lane · row 4
  5. auto lane · row 5
  6. auto lane · row 6
  7. auto lane · row 7
  8. auto lane · row 8
  9. auto lane · row 9
  10. auto lane · row 10
  11. auto lane · row 11
  12. auto lane · row 12
  13. auto lane · row 13
  14. auto lane · row 14
  15. auto lane · row 15
  16. auto lane · row 16
  17. auto lane · row 17
  18. auto lane · row 18
  19. auto lane · row 19
  20. auto lane · row 20
  21. auto lane · row 21
  22. auto lane · row 22
  23. auto lane · row 23
  24. auto lane · row 24
width wide
  1. wide lane · row 1
  2. wide lane · row 2
  3. wide lane · row 3
  4. wide lane · row 4
  5. wide lane · row 5
  6. wide lane · row 6
  7. wide lane · row 7
  8. wide lane · row 8
  9. wide lane · row 9
  10. wide lane · row 10
  11. wide lane · row 11
  12. wide lane · row 12
  13. wide lane · row 13
  14. wide lane · row 14
  15. wide lane · row 15
  16. wide lane · row 16
  17. wide lane · row 17
  18. wide lane · row 18
  19. wide lane · row 19
  20. wide lane · row 20
  21. wide lane · row 21
  22. wide lane · row 22
  23. wide lane · row 23
  24. wide lane · row 24
scroll-area-chrome-params-demo.svelte svelte
<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.

deriving the outline

deriveTocOutline scans the heading tree, slugs labels, stamps ids back — the ToC links are real fragments.

depth filler 1 — every section must be able to cross the line

depth filler 2 — every section must be able to cross the line

depth filler 3 — every section must be able to cross the line

depth filler 4 — every section must be able to cross the line

depth filler 5 — every section must be able to cross the line

depth filler 6 — every section must be able to cross the line

extents, not attributes

extents (heading → next heading) feed toc-engine directly: weights and pick without a single data attribute.

depth filler 1 — every section must be able to cross the line

depth filler 2 — every section must be able to cross the line

depth filler 3 — every section must be able to cross the line

depth filler 4 — every section must be able to cross the line

depth filler 5 — every section must be able to cross the line

depth filler 6 — every section must be able to cross the line

the line stays honest

the line is the viewport's own top — inner scrollers have no scaffold header, so the engine gets lineOffset from geometry.

depth filler 1 — every section must be able to cross the line

depth filler 2 — every section must be able to cross the line

depth filler 3 — every section must be able to cross the line

depth filler 4 — every section must be able to cross the line

depth filler 5 — every section must be able to cross the line

depth filler 6 — every section must be able to cross the line

zero boilerplate

ScrollArea.getViewport() + toc-outline + toc-engine: the whole linkage, zero handwritten ids.

depth filler 1 — every section must be able to cross the line

depth filler 2 — every section must be able to cross the line

depth filler 3 — every section must be able to cross the line

depth filler 4 — every section must be able to cross the line

depth filler 5 — every section must be able to cross the line

depth filler 6 — every section must be able to cross the line

toc-outline.ts ts
/**
 * 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

vertical (default)
  1. item 1
  2. item 2
  3. item 3
  4. item 4
  5. item 5
  6. item 6
  7. item 7
  8. item 8
  9. item 9
  10. item 10
  11. item 11
  12. item 12
horizontal
  1. card 1
  2. card 2
  3. card 3
  4. card 4
  5. card 5
  6. card 6
  7. card 7
  8. card 8
  9. card 9
  10. card 10
  11. card 11
  12. card 12
  13. card 13
  14. card 14
  15. card 15
  16. card 16
scroll-area-types-demo.svelte svelte
<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.

basic
<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>
horizontal
<ScrollArea orientation="horizontal" class="w-full" label="filmstrip">
  <!-- both axes ride the same capsule chrome; RTL mirrors through
       logical geometry -->
</ScrollArea>
toc-outline
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.

xs
  1. item 1
  2. item 2
  3. item 3
  4. item 4
  5. item 5
  6. item 6
  7. item 7
  8. item 8
  9. item 9
  10. item 10
sm
  1. item 1
  2. item 2
  3. item 3
  4. item 4
  5. item 5
  6. item 6
  7. item 7
  8. item 8
  9. item 9
  10. item 10
default
  1. item 1
  2. item 2
  3. item 3
  4. item 4
  5. item 5
  6. item 6
  7. item 7
  8. item 8
  9. item 9
  10. item 10
lg
  1. item 1
  2. item 2
  3. item 3
  4. item 4
  5. item 5
  6. item 6
  7. item 7
  8. item 8
  9. item 9
  10. item 10
TokenDefaultSource
--scrollbar-thumb / -hover / -activecurrentColor stepscomponent
--jx-scroll-thumb-radius0px (square-cut)component
--jx-scroll-track-w12px (width tiers: 8/12/16)component
--jx-scroll-padpad propcomponent

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

PropertyTypeDefaultDescription
orientation 'vertical' | 'horizontal' | 'both''vertical'Which axes scroll: overflow-y/x mapping.
label string'scrollable content'a11y name for the region.
pad string0Ring 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 | nullexportThe scrollport element — Toc scrollRoot / engine-direct linkage.

Universal props

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity 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.

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

one real query() case
<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()

  1. item 1
  2. item 2
  3. item 3
  4. item 4
  5. item 5
  6. item 6
  7. item 7
  8. item 8
  9. item 9
  10. item 10
  11. item 11
  12. item 12

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.

scroll-area-query-demo.svelte svelte
<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

line one

line two

line three

line four

medium via the alias ladder

the thumb-corner radius prop keeps its own name

radius 20 on the region; the auto card computes max(0px, 20px − 0.875rem)

scroll-area-universal.svelte svelte
<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

KeyAction
TabMoves focus through the scrollable area (the region, then the thumb)
↑ ↓ ← → / Home / End / PgUp / PgDnNative scrollport scrolling once the region is focused
arrows / PgUp / PgDn / Home / End on the thumbKeyboard-drag the thumb itself — steps, pages, jumps (role=scrollbar contract)
pointer drag / track clickThe thumb drags with pointer capture; a track click pages toward the click

ARIA

AttributeValueDescription
aria-labellabel propAccessible name for the region (default "scrollable content")
roleregionPlus tabindex=0 — the WAI scrollable-region pattern
role (thumb)scrollbarThe thumb's contract: aria-controls → the viewport, aria-valuenow tracking 0..100, aria-orientation, focusable, keyboard-draggable
the four pinsfocus-within / thumb focus / drag / hoverEach 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.

jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h