registry:ui · shared system
scroll run — one law set for every scrollable region
Born twice (tabs 2026-09-01, button-group 2026-09-04) and unified the same day by Owner ruling: the scrollable strip is not a tabs feature, it is a SYSTEM. The run is the scroller itself; a JS machine stamps the scrollability verdict (data-jx-scroll-state) that the css trusts for every overlay gate, plus per-member edge factors consumed SQUARED — the eased curve where light clips barely treat and deep clips ramp to full. The chrome (frosted edge chips, the merged veil layer) is real DOM keyed on that verdict; the treatment is a typed builder. Consumers keep only their tuning.
scroll run
THE raw contract, live — no tabs, no button-group, just the three parts wired by hand (this is exactly what a future scrollable region copies): a one-cell grid host, the run carrying its data hooks, ScrollChrome for the DOM half, and one createScrollStamp effect for the JS half. Round 2: flip the AXIS (the same contract against the block edges — up/down glyphs, block lane, block-axis veil), flip the treatment (the ONE ramp builder with its toggles), and switch on CUSTOM CHIPS (lucide ARROW snippets inside the frosted buttons — deliberately distinct geometry so the switch reads at a glance; centered, re-aiming with the axis). Round 3: the chips sit IN-BOARD flush against their edge, centered on the cross axis; the four direction glyphs are four separate css vars. Round 4: declare the chips DISABLED — they vanish from the DOM entirely (consumer-declared absence; the verdict stays the automatic gate for rendered chips). Flip CONTENT: FITS and the whole chrome retires — three lanes fit the stage, the verdict stamps 'none' (the childList observer re-verdicts; shrinking the stage does the same).
<script lang="ts">
import { createScrollStamp, ramp } from '@ui/scroll-run';
import ScrollChrome from '@ui/scroll-run';
</script>
<div class="jx-scroll-host grid max-w-full [grid-template-columns:minmax(0,1fr)]">
<!-- the run IS the scroller: one element, the shared sheet styles it
(data-axis 'horizontal' | 'vertical' picks the measuring axis and
the chip edges). NEVER hand-stamp data-scroll-effect — ScrollChrome
owns the effect attributes -->
<div
bind:this={runEl}
data-jx-scroll-run=""
data-axis="horizontal"
class="flex gap-2 overflow-x-auto"
>
<!-- …the strip's members… -->
</div>
<!-- the DOM half of the chrome: veil layer + chevron chips. Custom
chip content = snippet children (the frost/gating/button law stay,
the glyph retires) -->
<ScrollChrome scrollEffect={ramp()} run={runEl} backwardLabel="Scroll back" forwardLabel="Scroll on" />
</div>
<script lang="ts">
// the JS half: the verdict + the edge factors, one machine
$effect(() => {
const run = runEl;
if (!run) return;
const stamp = createScrollStamp({
run,
host: hostEl ?? null,
members: () => [...run.children].filter((c) => c instanceof HTMLElement),
ramps: true, // stamp --jx-edge-* only under ramp()
});
return () => stamp.destroy();
});
</script>the technique
The three parts
scroll-run.svelte.ts is the JS half: createScrollStamp owns the measurement pass — the verdict (none | start-closed | end-closed | open) stamped on the run, the host's --jx-scroll-progress (RTL-normalized), and each member's --jx-edge-start/end clipped fractions, with ResizeObserver plus visibilitychange/focus wake so a background tab restamps on return. The RTL trio (detectRtlScrollModel, rtlScrollToCanonical, rtlScrollFromCanonical) funnels all three engine models into one canonical [−max, 0] coordinate space; nudgeRun steps a page minus the padding lane. scroll-run.css is the law sheet: the run contract, the squared consumption, the chip and veil paint, the verdict gates. scroll-chrome.svelte is the DOM half of the chrome — the veil layer (progressBlur ladder or shadow bands) and the two chevron chips wired to nudgeRun (pointer shortcuts; the run is the keyboard path).
/* the eased curve — every ramp effect is this one shape:
the clipped fraction, consumed squared */
opacity: calc(1 - max(var(--jx-edge-start, 0), var(--jx-edge-end, 0))
* max(var(--jx-edge-start, 0), var(--jx-edge-end, 0)));types
Types
The treatment is a typed builder product (the press-button effect convention); the verdict is a four-state stamp; the host law is one grid cell.
The ONE member-ramp builder (the round-2 merge): each member is treated by its own clipped fraction as it crosses an edge — fade, blur, translate, any combination. Every toggle defaults ON; a toggle off never pays its css property (ramp({ blur: false }) is the old cheapest slide). Factors stamp only under ramp.
The veil: the progressive-blur ladder pinned as grid items of the host cell, entering by scroll-driven translate over the outer 15% of travel. The ladder is capped (max 4px by default tuning) for short strips.
The contrast ghost: the separator INK law as an edge band — backdrop-filter contrast() subtracts color toward mid tone (light dims, dark lifts), never adds black. One band per edge, mask-ramped.
data-jx-scroll-state: none (cannot scroll at all), start-closed / end-closed (that edge is exhausted), open. Every overlay gate keys on it — a dead direction never paints, and content that cannot scroll paints nothing at all: the verdict FOLLOWS the content (a childList MutationObserver re-stamps when members come or go).
.jx-scroll-host stacks the run, the veil layer and the chips in one grid cell — grid positions them, z-index layers them (run base · veil 1 · chips 2), never position:*. Either axis: the run's data-axis picks the measuring axis and the chip edges (inline or block), glyphs included.
A companion element can ride a member's factors (tabs mirrors them onto its selection indicator, so the bar exits with its trigger). Pure stamp-side wiring — the css needs nothing new.
usage
Usage
The whole adoption contract: host, run hooks, chrome, and one effect. This is every line a future scrollable region adds.
<script lang="ts">
import { createScrollStamp, ramp } from '@ui/scroll-run';
import ScrollChrome from '@ui/scroll-run';
</script>
<div class="jx-scroll-host grid max-w-full [grid-template-columns:minmax(0,1fr)]">
<!-- the run IS the scroller: one element, the shared sheet styles it
(data-axis 'horizontal' | 'vertical' picks the measuring axis and
the chip edges). NEVER hand-stamp data-scroll-effect — ScrollChrome
owns the effect attributes -->
<div
bind:this={runEl}
data-jx-scroll-run=""
data-axis="horizontal"
class="flex gap-2 overflow-x-auto"
>
<!-- …the strip's members… -->
</div>
<!-- the DOM half of the chrome: veil layer + chevron chips. Custom
chip content = snippet children (the frost/gating/button law stay,
the glyph retires) -->
<ScrollChrome scrollEffect={ramp()} run={runEl} backwardLabel="Scroll back" forwardLabel="Scroll on" />
</div>
<script lang="ts">
// the JS half: the verdict + the edge factors, one machine
$effect(() => {
const run = runEl;
if (!run) return;
const stamp = createScrollStamp({
run,
host: hostEl ?? null,
members: () => [...run.children].filter((c) => c instanceof HTMLElement),
ramps: true, // stamp --jx-edge-* only under ramp()
});
return () => stamp.destroy();
});
</script>a11y
Accessibility
The chips are real buttons with REQUIRED labels; the veils are scenery; nothing paints before the first verdict.
Keyboard
| Key | Action |
|---|---|
| ArrowLeft / ArrowRight | The run is a native scroller — keyboard travel is free; the chips are shortcuts, not the only path |
ARIA
| Attribute | Value | Description |
|---|---|---|
| backwardLabel / forwardLabel | required | Each chevron chip is a POINTER-SHORTCUT button — deliberately out of the Tab order; keyboard travel rides the run itself (Arrow keys scroll it natively). Accessible name still required (tabs says "Scroll tabs backward") |
| aria-hidden | veils | The veil layer and its bands are pure scenery — no name, no role, pointer-transparent |
| prefers-reduced-motion | translate: none | The translate dies (member ramps and veil entrances); blur and opacity stay — a CLOSED edge hides its veil outright instead of parking it in place |
theming
Theming
The glyph/veil knobs ride the HOST (the overlays are the run's siblings — a var on the run never reaches them); the ramp magnitudes are chrome-stamped on the RUN; the glyphs are FOUR swappable css vars, one per direction.
| Token | Default | Source |
|---|---|---|
| --jx-scroll-veil | calc(var(--jx-inset) * 1.5) | component |
| --jx-scroll-chevron-chip | oklab(1 0 0 / 0.8) | component |
| --jx-scroll-chevron-chip-hover | oklab(1 0 0 / 0.95) | component |
| --jx-scroll-chevron-size | 14px | component |
| --jx-scroll-chevron-left / -right / -up / -down | lucide chevrons | component |
| --jx-scroll-edge-slide / --jx-scroll-edge-blur | builder-set | component |
axes
Universal props
The eight-axis surface (explicit-props): size · shape · radius · density · color · theme · elevation · motion — each axis takes named steps, auto (inherit the ambient context; stamps nothing), an exact number (px · coefficient · dp · hue per axis), or query() for responsive/container-conditional values. The NO-ROOT (fragment) dialect: ScrollChrome renders a fragment (the optional veil layer + the two chevron chips — nothing guaranteed), so the family supplies the resolved lanes through CONTEXT only; the veil's composed ProgressiveBlur children stamp their own roots through the ambient chain. The stamp machine, the RTL engine and the law sheet stay outside the supply set.
Scroll run · universal props
<ScrollChrome scrollEffect={ramp()} run={runEl} size={18} density="small" />api
API
Two functions, one component, three builders — the whole surface. Consumers: tabs (TabsList scrollEffect) and button-group (overflow='scroll' + scrollEffect, both axes).
createScrollStamp(options) → ScrollStamp
| Property | Type | Default | Description |
|---|---|---|---|
| run * | HTMLElement | — | The scroller itself — must carry data-jx-scroll-run; its data-axis (horizontal | vertical) picks the measuring axis. The verdict lands on it. |
| host * | HTMLElement | — | The one-cell grid host (.jx-scroll-host) — the progress var and the css-var knobs land on it. |
| members * | () => HTMLElement[] | — | The ramp audience, re-read every pass; a childList MutationObserver re-verdicts when membership changes (content that stops overflowing retires the chrome). |
| ramps * | boolean | — | Stamp the per-member --jx-edge-* factors — true only under ramp(); the veil effects never pay the stamp loop. |
| mirrors | () => { target, source }[] | — | Companion elements that ride a member's factors (tabs' indicator mirrors its active trigger). |
| returns | ScrollStamp | — | { update() — restamp by hand (e.g. after DOM surgery), destroy() — tear down listeners and observers }. |
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) |
ScrollChrome props
| Property | Type | Default | Description |
|---|---|---|---|
| scrollEffect * | ScrollEffect | — | A builder product. THIS component owns the run's effect surface: the data-scroll-effect type key, the ramp's data-ramp-* toggle flags AND its magnitude vars (--jx-scroll-edge-slide/blur from the builder's distance/radius) — the consumer never hand-stamps any of them. |
| run * | HTMLElement | — | The scroller — the chips nudge it via nudgeRun (axis- and RTL-normalized), and its data-axis drives the chrome's axis awareness. |
| backwardLabel / forwardLabel | string | 'Scroll backward' / 'Scroll forward' | The chips' accessible names — tabs passes "Scroll tabs backward", button-group "Scroll actions backward". |
| backwardContent / forwardContent | Snippet | — | THE custom scroll-button lane: snippet children render INSIDE the chip buttons, CENTERED by the chip's own box law — the frost, the shape, the verdict gating and the focusable-button law all stay (the tabs-indicator paint-override pattern); only the glyph layer retires. Swapping just the glyph? Override the --jx-scroll-chevron-left/-right/-up/-down url vars instead (four independent slots). |
| backwardDisabled / forwardDisabled | boolean | false (rendered) | DECLARED absence (round 4, semantics finalized round 7): a disabled chip does not render AT ALL — no DOM node, no paint, no a11y entry. The verdict stays the AUTOMATIC gate for rendered chips (a closed edge or a cannot-scroll run never paints). |
builders (the press-button effect convention; round 2 — the merged ramp)
| Property | Type | Default | Description |
|---|---|---|---|
| ramp | (options?: { opacity?, blur?, translate?, distance?, radius? }) => ScrollEffect | all toggles true · distance '8px' · radius '4px' | The ONE member-ramp builder. Each member ramps by its clipped fraction as it crosses an edge (consumed squared); every toggle defaults ON, and a toggle off never pays its css property. ramp({ blur: false }) is the cheapest posture (the old slide), ramp({ translate: false }) the old blur. |
| progressBlur | (options?: { blurLevels?, width? }) => ScrollEffect | capped ladder | The progressive-blur veil pair on EITHER axis (inline edges start/end, block edges top/bottom — the grid dialect of the component); width overrides --jx-scroll-veil inline. |
| shadow | (options?: { width? }) => ScrollEffect | — | The contrast-ink veil pair — backdrop contrast subtracts color, never adds black; axis-aware (block-edge bands on a vertical run). |