mk-autoscroll
v0.1.0
Published
A tiny standalone autoscroll utility for caller-provided points.
Maintainers
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-autoscrollCore 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: theHTMLElementorWindowthat actually scrolleddeltaX: actual horizontal scroll delta after clampingdeltaY: actual vertical scroll delta after clampingscrollLeft: final horizontal scroll positionscrollTop: 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.clientXPointerEvent.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": usesmaxSpeedas 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": usesratio * ratio, starting slower near the inner boundary and ramping faster near the edge.- Custom function: receives a clamped ratio from
0to1; its return value is clamped to0..1. If it throws or returns a non-finite value, the effective ratio is0.
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.topscrolls up - a point below
rect.bottomscrolls down - a point left of
rect.leftscrolls left - a point right of
rect.rightscrolls 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, oroverlay - 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/pageYinstead ofclientX/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
HTMLElementand 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.
