npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

wts-scroll

v3.0.1

Published

Framework-agnostic scrolling toolkit with custom scrollbars, infinite and virtual scrolling, synchronized views, Web Components, RTL, and accessibility.

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

The 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/timeline exposes the native scroll-driven-animation standard through the internal viewport.
  • wts-scroll/sync synchronizes logical positions between scrollers without coupling their DOM.
  • wts-scroll/virtual is 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.