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

mk-autoscroll

v0.1.0

Published

A tiny standalone autoscroll utility for caller-provided points.

Readme

mk-autoscroll

mk-autoscroll is a standalone utility that scrolls configured containers while a caller-provided point is near their edges.

It is built around one public autoscroll instance creator: createAutoScroll(...). The package does not know what interaction produced the point. Pointer input, keyboard input, custom sensors, framework state, and app-specific interaction lifecycles all stay outside the core.

Installation

pnpm add mk-autoscroll

Core Mental Model

mk-autoscroll has three jobs:

  • You provide one or more scroll containers, or omit the container to use window.
  • You provide the current point in viewport/client coordinates.
  • The package decides whether a configured container should scroll and by how much.

It is not tied to drag-and-drop, React, overlays, or any specific interaction. It does not discover the point for you. Optional helper functions can help build a container list, but createAutoScroll(...) itself only uses the containers you pass or the default window container. The module can be imported without DOM globals; when no window exists, the default container resolves to null and autoscroll is a safe no-op until a real container is provided.

Minimal Usage

import { createAutoScroll } from "mk-autoscroll";

const autoScroll = createAutoScroll();

autoScroll.start();
autoScroll.updatePoint({ x: event.clientX, y: event.clientY });
autoScroll.stop();

Call start() when the owning interaction begins, call updatePoint(...) as the point changes, and call stop() when the interaction ends or the owner is torn down.

API Reference

createAutoScroll(input?)

Creates an autoscroll controller for the configured container input. If no container is configured, it uses window when available.

export function createAutoScroll(input?: CreateAutoScrollInput): AutoScroll;

The returned controller is small and stateful. Create it once for the owner of the interaction surface instead of recreating it on every pointer move.

AutoScroll

export type AutoScroll = {
  start: () => void;
  stop: () => void;
  updatePoint: (point: AutoScrollPoint | null) => void;
};
  • start() starts the RAF loop if it is not already running.
  • stop() cancels the RAF loop if it is running and clears the latest point.
  • updatePoint(point) stores the latest viewport/client-space point.
  • updatePoint(null) clears the point so no scrolling occurs until a point is provided again.

Repeated start() and stop() calls are safe.

Public Types

export type AutoScrollPoint = {
  x: number;
  y: number;
};

export type AutoScrollContainer = HTMLElement | Window;

export type AutoScrollContainerValue = AutoScrollContainer | null;

export type AutoScrollContainerInput =
  | AutoScrollContainerValue
  | readonly AutoScrollContainerValue[]
  | (() => AutoScrollContainerValue | readonly AutoScrollContainerValue[]);

export type AutoScrollAxis = "x" | "y" | "both";

export type AutoScrollAcceleration =
  | "constant"
  | "linear"
  | "ease-in"
  | ((distanceRatio: number) => number);

export type AutoScrollOutsideBehavior = "stop" | "continue";

export type AutoScrollChange = {
  container: AutoScrollContainer;
  deltaX: number;
  deltaY: number;
  scrollLeft: number;
  scrollTop: number;
};

export type AutoScrollEvent = {
  changes: readonly AutoScrollChange[];
};

export type CreateAutoScrollInput = {
  container?: AutoScrollContainerInput;
  axis?: AutoScrollAxis;
  edgeSize?: number;
  maxSpeed?: number;
  acceleration?: AutoScrollAcceleration;
  outsideBehavior?: AutoScrollOutsideBehavior;
  onScroll?: (event: AutoScrollEvent) => void;
};

export type GetScrollableAncestorsOptions = {
  axis?: AutoScrollAxis;
  includeWindow?: boolean;
};

Optional Helpers

export function getScrollableAncestors(
  element: Element | null,
  options?: GetScrollableAncestorsOptions,
): AutoScrollContainer[];

export function isScrollable(
  element: Element | Window,
  axis?: AutoScrollAxis,
): boolean;

These helpers are optional utilities. They do not change the behavior of createAutoScroll(...) unless you call them and pass their result as container.

createAutoScroll Options

| Option | Type | Default | Description | | --- | --- | --- | --- | | container | AutoScrollContainerInput | window | One container, null, an ordered array of containers/nulls, or a resolver returning one value or an array. If omitted, window is resolved lazily when available. | | axis | "x" \| "y" \| "both" | "both" | Limits scrolling to horizontal, vertical, or both axes. | | edgeSize | number | 48 | Distance in pixels from each enabled edge where scrolling can start. Non-positive or non-finite values fall back to the default. Effective edge size is clamped to half the container size on each axis. | | maxSpeed | number | 900 | Maximum scroll velocity in pixels per second. Non-positive or non-finite values fall back to the default. | | acceleration | "constant" \| "linear" \| "ease-in" \| ((distanceRatio: number) => number) | "linear" | Controls velocity inside the edge zone. "constant" uses maxSpeed immediately; ramping modes and custom functions use a clamped 0..1 distance ratio. | | outsideBehavior | "stop" \| "continue" | "stop" | Controls what happens when the point moves strictly past a container edge. "stop" preserves inside-only behavior; "continue" keeps scrolling in the outside edge direction. | | onScroll | (event: AutoScrollEvent) => void | undefined | Optional callback fired once per animation frame after autoscroll actually changes one or more scroll positions. |

Reacting To Scroll Changes

onScroll fires after autoscroll writes have actually changed one or more scroll positions. It is batched once per animation frame and does not fire on frames where no scroll position changes, when the active point is cleared with updatePoint(null), or after stop() cancels the loop.

const autoScroll = createAutoScroll({
  container,
  onScroll(event) {
    for (const change of event.changes) {
      console.log(change.deltaY, change.scrollTop);
    }
  },
});

Each event has this shape:

export type AutoScrollEvent = {
  changes: readonly AutoScrollChange[];
};

export type AutoScrollChange = {
  container: AutoScrollContainer;
  deltaX: number;
  deltaY: number;
  scrollLeft: number;
  scrollTop: number;
};

AutoScrollEvent.changes is a readonly list of actual scroll changes from that RAF frame. Each AutoScrollChange describes one container that moved:

  • container: the HTMLElement or Window that actually scrolled
  • deltaX: actual horizontal scroll delta after clamping
  • deltaY: actual vertical scroll delta after clamping
  • scrollLeft: final horizontal scroll position
  • scrollTop: final vertical scroll position

When multiple or nested containers scroll in one frame, the event contains one change per container that actually moved. If one container handles both axes, it produces one change with both deltas. If separate containers handle different axes, both changes are included in the same event.

The package does not estimate content size. It reads current scroll limits during the animation loop. Consumers can use onScroll to refresh their own measurements or external state; DOM or content changes caused by the callback are observed on later RAF frames, not during the already-running fallback pass.

Keep callback work lightweight. Callback errors are not swallowed.

Container Configuration

If the container option is omitted, createAutoScroll(...) uses window when available:

createAutoScroll();

createAutoScroll({});

The container option also accepts all of these explicit shapes:

createAutoScroll({
  container: scrollElement,
});

createAutoScroll({
  container: window,
});

createAutoScroll({
  container: null,
});

createAutoScroll({
  container: [innerElement, outerElement, window],
});

createAutoScroll({
  container: () => currentScrollElement,
});

createAutoScroll({
  container: () => [laneElement, boardElement, window],
});

Use the omitted container for page scrolling. Use a static container when a specific element is known up front. Use null or a resolver when the container may mount later, be replaced, temporarily disappear, or intentionally disable scrolling.

The default window container is resolved lazily, so createAutoScroll() can be called in non-browser environments without immediately reading window. If no window exists, the default resolves to null and does not scroll.

Resolvers are not called during createAutoScroll(...) construction. They are checked when start() runs and during the RAF loop. If container is explicitly null, or if a resolver returns null, that frame does not scroll and the package does not fall back to window. Disconnected HTMLElement containers are skipped. Window containers remain supported.

For nested containers, pass candidates from innermost to outermost. This lets an inner scroller handle an axis while it can still scroll, then allows an outer scroller or window to take over when the inner scroller is exhausted.

Point Updates

Points use viewport/client coordinates:

  • PointerEvent.clientX
  • PointerEvent.clientY
  • the same coordinate space as getBoundingClientRect()

Do not pass page coordinates such as pageX or pageY unless you convert them to client coordinates first.

autoScroll.updatePoint({
  x: event.clientX,
  y: event.clientY,
});

Call updatePoint(null) to clear the active point. Non-finite coordinates such as NaN or Infinity are treated as no active point and do not scroll. Call stop() when the interaction ends so the RAF loop is canceled.

Axis And Velocity

axis controls which axes are allowed to scroll:

  • "x": horizontal only
  • "y": vertical only
  • "both": horizontal and vertical

A point must be inside a container rect and inside an enabled edge zone for scrolling to occur. For HTMLElement containers, rects come from getBoundingClientRect(). For window, the viewport is used.

edgeSize defines the edge zone size in pixels. For example, with edgeSize: 48, scrolling can begin when the point is within 48 pixels of an enabled edge. If edgeSize is larger than half of a container's size on an axis, the effective edge size is clamped to half of that axis size so zones do not overlap.

maxSpeed is measured in pixels per second. Scrolling is time-based using RAF timestamps, so behavior is not tied to a specific frame rate.

acceleration controls how speed is chosen inside an edge zone:

  • "constant": uses maxSpeed as soon as the point enters an active edge zone, with no ramping. This is useful when you want predictable fixed-speed scrolling.
  • "linear": uses the distance ratio directly.
  • "ease-in": uses ratio * ratio, starting slower near the inner boundary and ramping faster near the edge.
  • Custom function: receives a clamped ratio from 0 to 1; its return value is clamped to 0..1. If it throws or returns a non-finite value, the effective ratio is 0.
const autoScroll = createAutoScroll({
  container,
  acceleration: "constant",
  maxSpeed: 600,
});

Continuing Outside The Container

By default, outsideBehavior: "stop" preserves inside-only behavior. A point must be inside the container bounds for an axis to activate scrolling on that axis.

Use outsideBehavior: "continue" when scrolling should continue after the point moves strictly past a container edge:

const autoScroll = createAutoScroll({
  container: listElement,
  axis: "y",
  edgeSize: 48,
  outsideBehavior: "continue",
});

edgeSize still controls activation inside the container. Outside continuation is separate: it is not an extended edge zone and does not add an outside distance limit.

When outsideBehavior is "continue":

  • a point above rect.top scrolls up
  • a point below rect.bottom scrolls down
  • a point left of rect.left scrolls left
  • a point right of rect.right scrolls right

Outside continuation treats the distance ratio as 1, so built-in acceleration modes scroll at maxSpeed. Custom acceleration functions receive 1, and their result is still clamped to 0..1.

Scroll limits still apply, and nested fallback still uses the caller-provided container order. For vertical outside continuation, the point's x must remain within the container's horizontal bounds. For horizontal outside continuation, the point's y must remain within the container's vertical bounds. This prevents diagonal points far away from scrolling unrelated containers.

Exact container boundaries are still treated as inside edge positions. For example, point.y === rect.bottom uses normal inside edge-zone behavior; point.y > rect.bottom uses outside continuation when enabled.

Multiple And Nested Containers

When container resolves to an array, containers are evaluated in caller-provided order.

For each axis:

  • The point must be inside the candidate container's rect.
  • The point must be inside the relevant edge zone.
  • The requested scroll must produce actual movement after scroll limits are clamped.
  • The first container that actually moves handles that axis for the frame.

The package does not scroll every eligible container at once by default. The x and y axes can be handled by different containers in the same frame if those axes are first moved by different containers.

const autoScroll = createAutoScroll({
  container: () => [laneElement, boardElement, window],
  axis: "both",
});

Pass nested containers from inner to outer: [inner, outer, window].

Scrollable Ancestor Helpers

The core stays explicit. It does not automatically discover ancestors. If you already have an element and want help building a container list, use getScrollableAncestors(...).

import { createAutoScroll, getScrollableAncestors } from "mk-autoscroll";

const autoScroll = createAutoScroll({
  container: () => getScrollableAncestors(anchorElement),
});

getScrollableAncestors(element, options) walks parent elements from nearest parent to document root and returns scrollable containers in innermost-to-outermost order. By default, it appends window when the document can scroll for the requested axis.

const containers = getScrollableAncestors(anchorElement, {
  axis: "y",
  includeWindow: true,
});

If you pass container: () => getScrollableAncestors(element), ancestor discovery runs while the RAF loop is active. That is useful when the ancestor list can change; otherwise, cache the helper result and pass the cached container list.

getScrollableAncestors(null) returns [].

Use isScrollable(element, axis) when you need custom filtering:

if (isScrollable(panel, "y")) {
  // panel can scroll vertically
}

For HTMLElement, scrollability requires both:

  • axis-specific overflow of auto, scroll, or overlay
  • scroll dimensions larger than client dimensions

visible, hidden, and clip are treated as not scrollable for autoscroll purposes. For window, scrollability is based on document dimensions compared with viewport dimensions. Axis "both" means either axis can scroll.

Helper options:

| Option | Type | Default | Description | | --- | --- | --- | --- | | axis | "x" \| "y" \| "both" | "both" | Filters ancestors by horizontal, vertical, or either-axis scrollability. | | includeWindow | boolean | true | Appends window when the document can scroll for the requested axis. |

React Usage

There is no React-specific API. React can use the same function with refs and cleanup.

import { useEffect, useRef } from "react";
import { createAutoScroll, type AutoScroll } from "mk-autoscroll";

function Example() {
  const scrollRef = useRef<HTMLElement | null>(null);
  const autoScrollRef = useRef<AutoScroll | null>(null);

  useEffect(() => {
    autoScrollRef.current = createAutoScroll({
      container: () => scrollRef.current,
      axis: "y",
    });

    return () => {
      autoScrollRef.current?.stop();
      autoScrollRef.current = null;
    };
  }, []);

  return <div ref={scrollRef} />;
}

The same start(), updatePoint(...), and stop() calls apply from whatever interaction code owns the point.

Interaction Example

Create the autoscroll instance outside the move handler. Start it when the interaction starts, update the point as the pointer moves, and clear/stop it when the interaction ends or is canceled.

const autoScroll = createAutoScroll({
  container: scrollContainer,
  axis: "y",
});

function onPointerDown() {
  autoScroll.start();
}

function onPointerMove(event: PointerEvent) {
  autoScroll.updatePoint({
    x: event.clientX,
    y: event.clientY,
  });
}

function onPointerUp() {
  autoScroll.updatePoint(null);
  autoScroll.stop();
}

function onPointerCancel() {
  autoScroll.updatePoint(null);
  autoScroll.stop();
}

Demo

The existing vanilla sortable list demo in apps/web is wired to mk-autoscroll as a consumer validation harness. It creates one autoscroll instance for the sortable list scroll container, starts it when the interaction starts, updates it with the current client-space point, and stops it when the interaction ends or the demo is torn down.

That demo is an example consumer only. mk-autoscroll itself is not sortable-specific and does not expose sortable, drag-and-drop, overlay, rect, or bounds APIs.

Cleanup

Call stop() whenever the owning interaction ends or the owning component/view is torn down. stop() cancels the RAF loop and clears the latest point.

Calling updatePoint(null) before stop() is useful when you want to explicitly clear the active point in your interaction lifecycle. Calling both is safe:

autoScroll.updatePoint(null);
autoScroll.stop();

Repeated stop() calls are safe.

Common Mistakes

  • Forgetting to call stop() when the interaction ends.
  • Passing pageX/pageY instead of clientX/clientY.
  • Recreating the autoscroll instance on every pointer move.
  • Passing nested containers in outer-to-inner order instead of inner-to-outer order.
  • Expecting the package to discover the point automatically.
  • Expecting createAutoScroll(...) to discover scroll ancestors automatically.
  • Assuming React needs a separate adapter.
  • Passing a disconnected HTMLElement and expecting it to scroll.

Limitations

  • No drag-and-drop API.
  • No rect or bounds update API.
  • No overlay API.
  • No framework adapter.
  • No automatic point discovery.
  • No automatic ancestor discovery inside createAutoScroll(...).
  • Helper-based ancestor discovery is optional and must be called by the consumer.
  • The package scrolls the first container that actually moves per axis; it does not scroll every eligible container simultaneously.