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

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.

the run IS the scrollerboth axes JS-stamped verdict squared edge factors · one ramp() builder RTL three-engine funnel

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

lane-01lane-02lane-03lane-04lane-05lane-06lane-07lane-08lane-09lane-10lane-11lane-12lane-13lane-14
usage.svelte svelte
<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 squared law
/* 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.

ramp({ opacity, blur, translate })

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.

progressBlur({ blurLevels, width })

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.

shadow({ width })

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.

the verdict stamp

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

the one-cell host

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

mirrors

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.

the raw contract
<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

KeyAction
ArrowLeft / ArrowRightThe run is a native scroller — keyboard travel is free; the chips are shortcuts, not the only path

ARIA

AttributeValueDescription
backwardLabel / forwardLabelrequiredEach 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-hiddenveilsThe veil layer and its bands are pure scenery — no name, no role, pointer-transparent
prefers-reduced-motiontranslate: noneThe 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.

TokenDefaultSource
--jx-scroll-veilcalc(var(--jx-inset) * 1.5)component
--jx-scroll-chevron-chipoklab(1 0 0 / 0.8)component
--jx-scroll-chevron-chip-hoveroklab(1 0 0 / 0.95)component
--jx-scroll-chevron-size14pxcomponent
--jx-scroll-chevron-left / -right / -up / -downlucide chevronscomponent
--jx-scroll-edge-slide / --jx-scroll-edge-blurbuilder-setcomponent

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

lane-01lane-02lane-03lane-04lane-05lane-06lane-07lane-08lane-09lane-10lane-11lane-12lane-13lane-14
scroll-run-universal.svelte svelte
<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

PropertyTypeDefaultDescription
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

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)

ScrollChrome props

PropertyTypeDefaultDescription
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 booleanfalse (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)

PropertyTypeDefaultDescription
ramp (options?: { opacity?, blur?, translate?, distance?, radius? }) => ScrollEffectall 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? }) => ScrollEffectcapped ladderThe 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).
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h