wts-scroll
v3.0.1
Published
Framework-agnostic scrolling toolkit with custom scrollbars, infinite and virtual scrolling, synchronized views, Web Components, RTL, and accessibility.
Maintainers
Readme
wts-scroll
Dependency-free custom scrollbars for vanilla JavaScript and any browser framework.
Version 3 provides two APIs:
WtsScroll, an imperative TypeScript/JavaScript controller.<wts-scroll>, a standards-based Web Component.
Both APIs keep native browser scrolling as the source of truth, support mouse, touch, keyboard, and programmatic scrolling, and have no Angular runtime dependency.
Modern scrolling features include bidirectional pagination without visual
jumps, mount/refresh reach evaluation for underfilled feeds, scroll-end and
overflow notifications, interaction-aware tracks, position persistence,
element alignment, overscroll containment, native scroll snap, and
primary-axis wheel routing.
Install
npm install wts-scrollThe published runtime has no dependencies and supports Node.js 18 or newer for
server-side imports. Building, testing, or publishing from this repository uses
newer tooling and requires Node.js ^22.22.2, ^24.15.0, or >=26.0.0, as
declared in devEngines. npm warns when repository commands run on a different
development runtime.
Required sizing
The mount target or <wts-scroll> element must have a definite height. Horizontal scrolling also requires a definite width and content wider than the viewport.
.scroll-host,
wts-scroll {
display: block;
width: 100%;
height: 24rem;
min-width: 0;
min-height: 0;
}When the scroller is inside a flex or grid layout, its ancestors may also need min-height: 0 and min-width: 0.
Controller
The controller moves the mount target's existing children into its native scrolling viewport. destroy() removes owned DOM and restores those children to the target.
<div id="feed" class="scroll-host">
<article>First item</article>
<article>More content...</article>
</div>import { WtsScroll } from 'wts-scroll';
const host = document.querySelector('#feed')!;
const scroll = new WtsScroll(host, {
direction: 'vertical',
autoHide: true,
reachEndOffset: '10%',
minThumbSize: 28,
onReachEnd(detail) {
console.log('Load more', detail);
},
});
host.addEventListener('scroll', (event) => {
const detail = (event as CustomEvent).detail;
console.log(detail.progressY);
});
scroll.scrollTo({ top: 240, behavior: 'smooth' });
// When the owning view is removed:
scroll.destroy();A selector, Element, or ShadowRoot can be used as the target:
new WtsScroll('#feed');
new WtsScroll(document.querySelector('#feed')!);
new WtsScroll(shadowRoot);Infinite and bidirectional pagination
Use the existing reach callbacks for both directions. preservePosition()
keeps the same content at the same visual position while earlier items are
prepended, including when rendering is asynchronous or item heights vary:
const scroll = new WtsScroll('#timeline', {
direction: 'vertical',
reachStartOffset: '15%',
reachEndOffset: '15%',
evaluateReachOnMount: true,
evaluateReachOnRefresh: true,
async onReachStart() {
await scroll.preservePosition(async () => {
const previous = await loadPreviousPage();
prependItems(previous);
await nextRender();
});
},
async onReachEnd() {
appendItems(await loadNextPage());
},
});evaluateReachOnMount and evaluateReachOnRefresh let a short first page
request more content even when it does not yet overflow. Reach events remain
entry-based and rearm after content growth, avoiding repeated requests while
the same threshold stays active.
Position and navigation
Save and restore a logical, RTL-safe position across tabs, routes, or remounts:
const state = scroll.saveState();
sessionStorage.setItem('feed-scroll', JSON.stringify(state));
scroll.restoreState(
JSON.parse(sessionStorage.getItem('feed-scroll')!),
{ mode: 'progress' },
);Align a descendant without calculating offsets:
scroll.scrollToElement(document.querySelector('#message-42')!, {
block: 'center',
inline: 'nearest',
behavior: 'smooth',
offset: 12,
});Modern native behavior
const scroll = new WtsScroll('#gallery', {
trackVisibility: 'interaction',
autoHideDelay: 800,
overscrollBehavior: 'contain',
scrollSnap: 'x mandatory',
wheelAxis: 'primary',
scrollEndDelay: 120,
});
scroll.root.addEventListener('scroll-end', ({ detail }) => {
saveProgress(detail);
});
scroll.root.addEventListener('overflow-change', ({ detail }) => {
console.log(detail.overflowX, detail.overflowY);
});scroll-end uses the native event when the browser provides it and a
configurable quiet-period fallback otherwise. Overscroll and snap use the
corresponding CSS standards; unsupported enhancements degrade to ordinary
native scrolling. The core scrollbar and reach behavior do not depend on these
progressive browser features.
Web Component
Importing wts-scroll/element registers <wts-scroll> once. The registration is guarded when customElements is unavailable.
import 'wts-scroll/element';<wts-scroll
class="scroll-host"
direction="vertical"
autohide="true"
track-visibility="interaction"
auto-hide-delay="800"
reach-end-offset="10%"
evaluate-reach-on-mount="true"
evaluate-reach-on-refresh="true"
min-thumb-size="28"
overscroll-behavior="contain"
scroll-end-delay="120"
scroll-snap="y proximity"
wheel-axis="primary"
aria-label="Notifications"
>
<article>First notification</article>
<article>More notifications...</article>
</wts-scroll>
<script type="module">
const element = document.querySelector('wts-scroll');
element.addEventListener('reach-end', (event) => {
console.log('Load more', event.detail);
});
element.scrollToEnd({ behavior: 'smooth' });
</script>Use defineWtsScrollElement() when registration must be explicit or use a different tag name:
import { defineWtsScrollElement } from 'wts-scroll/element';
defineWtsScrollElement('app-scroll');Web Component attributes
| Attribute | Values | Default | Purpose |
| --- | --- | --- | --- |
| direction | both, vertical, horizontal | both | Enables scrolling and custom tracks by axis. |
| autohide | true/false, 1/0 | true | Hides tracks whose axes do not overflow. |
| track-visibility | always, overflow, interaction | overflow | Controls when enabled tracks are visible. |
| auto-hide-delay | milliseconds | 800 | Delay before interaction tracks fade. |
| reach-start-offset | pixels or percentage | 0 | Start threshold, such as 24, 24px, or 10%. |
| reach-end-offset | pixels or percentage | 0 | End threshold, such as 100px or 15%. |
| evaluate-reach-on-mount | true/false, 1/0 | false | Evaluates reach thresholds after mounting, including underfilled content. |
| evaluate-reach-on-refresh | true/false, 1/0 | false | Evaluates reach thresholds on explicit and observed refreshes. |
| min-thumb-size | number | 24 | Minimum thumb size in pixels; values below 8 normalize to the default. |
| wheel-multiplier | number | 1 | Multiplies wheel deltas; 1 preserves native wheel handling. |
| wheel-axis | native, primary | native | Routes wheel input normally or along the configured primary axis. |
| overscroll-behavior | auto, contain, none | auto | Controls scroll chaining at viewport boundaries. |
| scroll-snap | CSS scroll-snap-type value | none | Enables native scroll snapping, such as x mandatory. |
| scroll-end-delay | milliseconds | 120 | Debounce used when native scrollend is unavailable. |
| aria-label | string | Scrollable content | Accessible viewport label. |
The element also exposes options, direction, autoHide,
trackVisibility, overflowState, controller, and viewport properties,
plus the controller navigation and state methods.
Framework usage
wts-scroll has no framework adapter. Frameworks use the Web Component and standard DOM events directly.
React
import { createElement, useEffect, useRef } from 'react';
import 'wts-scroll/element';
import type { WtsScrollElement } from 'wts-scroll/element';
export function Feed() {
const scrollRef = useRef<WtsScrollElement>(null);
useEffect(() => {
const element = scrollRef.current;
const loadMore = () => console.log('Load more');
element?.addEventListener('reach-end', loadMore);
return () => element?.removeEventListener('reach-end', loadMore);
}, []);
return createElement(
'wts-scroll',
{
ref: scrollRef,
direction: 'vertical',
'reach-end-offset': '10%',
style: { display: 'block', height: '24rem' },
},
<div>Scrollable React content</div>,
);
}Angular
Import the element registration and allow custom elements in the component schema. No Angular adapter or Angular component is provided.
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import 'wts-scroll/element';
@Component({
selector: 'app-feed',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<wts-scroll
style="display:block;height:24rem"
direction="vertical"
reach-end-offset="10%"
(reach-end)="loadMore()"
>
<article>Scrollable Angular content</article>
</wts-scroll>
`,
})
export class FeedComponent {
loadMore(): void {
console.log('Load more');
}
}Vue
Configure Vue's template compiler to recognize the custom element. For Vue with Vite:
// vite.config.ts
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag === 'wts-scroll',
},
},
}),
],
});<script setup lang="ts">
import { ref } from 'vue';
import 'wts-scroll/element';
import type { WtsScrollElement } from 'wts-scroll/element';
const scroll = ref<WtsScrollElement>();
function loadMore(): void {
console.log('Load more');
}
</script>
<template>
<wts-scroll
ref="scroll"
style="display: block; height: 24rem"
direction="vertical"
reach-end-offset="10%"
@reach-end="loadMore"
>
<article>Scrollable Vue content</article>
</wts-scroll>
</template>Options
WtsScrollOptions is accepted by the controller constructor, setOptions(), and the Web Component's options property.
| Option | Type | Default | Purpose |
| --- | --- | --- | --- |
| direction | 'both' \| 'vertical' \| 'horizontal' | 'both' | Enables scrolling and tracks by axis. |
| autoHide | boolean | true | Hides a track when its axis does not overflow. |
| trackVisibility | 'always' \| 'overflow' \| 'interaction' | 'overflow' | Shows tracks continuously, only for overflow, or during interaction. |
| autoHideDelay | number | 800 | Milliseconds before interaction tracks fade. |
| reachStartOffset | number \| string | 0 | Distance from the primary-axis start; numbers are pixels and % is supported. |
| reachEndOffset | number \| string | 0 | Distance from the primary-axis end; numbers are pixels and % is supported. |
| evaluateReachOnMount | boolean | false | Evaluates thresholds after initial layout, useful for underfilled feeds. |
| evaluateReachOnRefresh | boolean | false | Evaluates thresholds whenever geometry refreshes. |
| minThumbSize | number | 24 | Minimum thumb size in pixels. |
| wheelMultiplier | number | 1 | Wheel delta multiplier; 1 keeps native handling. |
| wheelAxis | 'native' \| 'primary' | 'native' | Uses native wheel axes or routes deltas to the primary axis. |
| overscrollBehavior | 'auto' \| 'contain' \| 'none' | 'auto' | Controls native overscroll chaining. |
| scrollSnap | string | 'none' | Native CSS scroll-snap-type value. |
| scrollEndDelay | number | 120 | Fallback scroll-end debounce in milliseconds. |
| ariaLabel | string | 'Scrollable content' | Accessible label for the viewport. |
| injectStyles | boolean | true | Injects default controller styles into its mount target. |
| onScroll | (detail: WtsScrollEventDetail) => void \| Promise<void> | — | Called for native viewport scroll activity. |
| onReachStart | (detail: WtsScrollEventDetail) => void \| Promise<void> | — | Called when the start threshold becomes active. |
| onReachEnd | (detail: WtsScrollEventDetail) => void \| Promise<void> | — | Called when the end threshold becomes active. |
Deprecated v2-compatible option aliases are temporarily accepted: autohide, onReachStartOffset, onReachEndOffset, and speed. New code should use the canonical v3 names above.
For direction: 'both', the vertical axis is the primary axis used by start/end thresholds. For direction: 'horizontal', the horizontal axis is primary.
Events
The controller root and Web Component emit bubbling, composed CustomEvents:
| Event | When it fires |
| --- | --- |
| scroll | The native viewport scrolls. |
| scroll-end | Scrolling settles (native where supported, debounced fallback elsewhere). |
| reach-start | The primary axis enters the configured start threshold. |
| reach-end | The primary axis enters the configured end threshold. |
| overflow-change | Enabled-axis overflow state changes after layout or content updates. |
Reach events fire once on threshold entry. They can fire again after scrolling outside the threshold and re-entering it. Increasing the primary axis's scrollable size also rearms both reach callbacks, so appending or prepending a page cannot leave infinite scrolling latched inside a non-zero threshold.
Callbacks may be synchronous or asynchronous. A callback failure is reported to the browser without interrupting the controller's remaining scroll and reach processing.
Each event's detail is a WtsScrollEventDetail:
interface WtsScrollEventDetail {
axis: 'vertical' | 'horizontal';
top: number;
left: number;
maxTop: number;
maxLeft: number;
progressX: number;
progressY: number;
atStart: boolean;
atEnd: boolean;
originalEvent?: Event;
}Horizontal values are logical in right-to-left layouts: left: 0 is the
inline start (the right edge), and left: maxLeft is the inline end. The
controller normalizes browser-specific viewport.scrollLeft models for event
details, progress, ARIA values, and its scrolling methods.
overflow-change uses WtsScrollOverflowDetail, which reports overflowX
and overflowY together with the current maximum scroll distances.
Methods and properties
WtsScroll
| Member | Description |
| --- | --- |
| root | DOM root owned by the controller. |
| viewport | Native scrolling viewport. |
| content | Content wrapper inside the native viewport. |
| currentOptions | Read-only snapshot of normalized options. |
| setOptions(options) | Updates part of the configuration and refreshes geometry. |
| refresh() | Recalculates overflow, tracks, thumbs, and reach state. |
| scrollTo(options) / scrollTo(x, y) | Scrolls to absolute coordinates; horizontal values are logical in RTL. |
| scrollBy(options) / scrollBy(x, y) | Scrolls by relative coordinates; horizontal values are logical in RTL. |
| scrollToStart(options?) | Scrolls the primary axis to its start. |
| scrollToEnd(options?) | Scrolls the primary axis to its end. |
| scrollToElement(element, options?) | Aligns a descendant with logical block/inline alignment and optional offsets. |
| saveState() | Captures logical scroll position and progress for later restoration. |
| restoreState(state, options?) | Restores saved state, optionally preferring coordinates or progress. |
| preservePosition(mutation, options?) | Runs an async/sync DOM mutation while preserving the visible anchor position. |
| overflowState | Current horizontal and vertical overflow snapshot. |
| destroy() | Removes listeners/observers, owned DOM, and restores target children. |
WtsScrollElement exposes the same scrolling, state, preservation, and refresh
methods, plus controller, viewport, options, direction, autoHide,
trackVisibility, and overflowState.
Optional entry points
The core and Web Component stay dependency-free. Larger or specialized behaviors are opt-in:
wts-scroll/timelineexposes the native scroll-driven-animation standard through the internal viewport.wts-scroll/syncsynchronizes logical positions between scrollers without coupling their DOM.wts-scroll/virtualis a fixed-size, render-callback-based virtual list for large collections.
Import only the entry point a view needs; none of them are required by
WtsScroll or <wts-scroll>.
Scroll timeline
import {
createWtsScrollTimeline,
supportsWtsScrollTimeline,
} from 'wts-scroll/timeline';
const timeline = createWtsScrollTimeline(scroll, { axis: 'block' });
if (supportsWtsScrollTimeline() && timeline) {
card.animate(keyframes, { timeline });
}This helper progressively uses the native ScrollTimeline API and returns
null when it is unavailable or during SSR; it does not install a polyfill.
Synchronized scrollers
import { WtsScrollSync } from 'wts-scroll/sync';
const comparison = new WtsScrollSync([leftScroll, rightScroll], {
axis: 'vertical',
});
// Later:
comparison.destroy();Synchronization uses logical progress, including normalized horizontal RTL coordinates, so differently sized documents remain aligned.
Fixed-size virtual list
import { WtsVirtualScroll } from 'wts-scroll/virtual';
const list = new WtsVirtualScroll('#large-list', {
items,
itemSize: 48,
overscan: 4,
renderItem(item) {
const row = document.createElement('article');
row.textContent = item.title;
return row;
},
});Virtual scrolling is deliberately fixed-size and render-callback based. It does not own framework templates or perform variable-height measurement, which keeps its behavior deterministic and its core independent of UI frameworks.
Styling
Default styles are injected automatically. Web Component base styles always remain in its shadow root so native overflow, sizing, and accessibility behavior keep working.
For a controller in light DOM, styles can instead be loaded as a stylesheet:
import 'wts-scroll/styles.css';
const scroll = new WtsScroll('#feed', {
injectStyles: false,
});CSS custom properties
Set variables on the controller mount target or the <wts-scroll> element:
| Property | Default |
| --- | --- |
| --wts-scroll-y-track-background | translucent currentColor |
| --wts-scroll-y-track-thumb-background | translucent currentColor |
| --wts-scroll-y-track-thumb-border-radius | 999px |
| --wts-scroll-y-track-width | 12px |
| --wts-scroll-y-track-thumb-width | 8px |
| --wts-scroll-x-track-background | translucent currentColor |
| --wts-scroll-x-track-thumb-background | translucent currentColor |
| --wts-scroll-x-track-thumb-border-radius | 999px |
| --wts-scroll-x-track-height | 12px |
| --wts-scroll-x-track-thumb-height | 8px |
| --wts-scroll-track-inset | 2px |
| --wts-scroll-track-opacity | 1 |
| --wts-scroll-track-transition | opacity 160ms ease |
wts-scroll {
--wts-scroll-y-track-width: 10px;
--wts-scroll-y-track-thumb-width: 6px;
--wts-scroll-y-track-thumb-background: #5b5bd6;
--wts-scroll-track-opacity: 0.8;
}Shadow parts
The Web Component exposes:
| Part | Element |
| --- | --- |
| viewport | Native scrolling viewport |
| content | Content wrapper |
| track-y | Vertical track |
| thumb-y | Vertical thumb |
| track-x | Horizontal track |
| thumb-x | Horizontal thumb |
wts-scroll::part(track-y) {
border-radius: 999px;
}
wts-scroll::part(thumb-y) {
box-shadow: 0 0 0 1px rgb(0 0 0 / 20%);
}TypeScript exports
The root entry exports WtsScroll, its option/event/state types (including
WtsScrollOverflowDetail), the compatibility WtsScrollBarOptions alias, and
WTS_SCROLL_STYLES.
The wts-scroll/element entry exports WtsScrollElement, WtsScrollElementEventMap, WTS_SCROLL_TAG_NAME, and defineWtsScrollElement.
