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

@yorozu/sortable

v1.0.151

Published

framework-agnostic pointer drag session for reordering lists

Readme

@yorozu/sortable

Framework-free pointer session for reordering a full array while only some item nodes are registered. You own the DOM, paint offsets yourself, and decide when drag is armed; this package tracks insert index, sibling shifts, and edge auto-scroll.

Install

pnpm add @yorozu/sortable

Session

import {
    createSortableSession,
    paintSortableTransforms,
    POINTER_ACTIVATION,
    type SortableSession,
} from "@yorozu/sortable"

let items = ["a", "b", "c"]
let nodes = new Map<string, HTMLElement>()

let session: SortableSession = createSortableSession({
    axis: "y",
    getItems: () => items,
    getKey: (item) => item,
    activation: POINTER_ACTIVATION,
    onReorder: (next) => {
        items = next
        render()
    },
})

session.subscribe(() => paint())

function bindRow(node: HTMLElement, key: string): void {
    let handle = session.registerItem(node, key)
    nodes.set(key, node)
    node.addEventListener("pointerdown", (e) => session.pointerDown(key, e))
    // later: handle.update(newKey) / handle.destroy(); nodes.delete(key)
}

function paint(): void {
    // Optional { reduced: true } overrides lift/transition per call when the host
    // did not pass reducedMotion on the session.
    paintSortableTransforms(session, nodes)
    // Overlay (if any) still uses session.getOverlayOffset() separately.
}

Wrapping flow

For a wrapping flex / chip strip (items reflow in 2d, not a fixed grid), use createSortableBothAxis and paintSortableFlowTransforms. Offsets are {x, y} from session.getOffset(key). Hosts must register every key in getItems(); missing nodes are omitted from the layout snapshot, not estimated.

import {
    createSortableBothAxis,
    paintSortableFlowTransforms,
    HOLD_ACTIVATION,
    type SortableBothAxis,
} from "@yorozu/sortable"

let items = ["a", "b", "c", "d"]
let nodes = new Map<string, HTMLElement>()

let session: SortableBothAxis = createSortableBothAxis({
    getItems: () => items,
    getKey: (item) => item,
    // HOLD_ACTIVATION when the wrapping strip itself scrolls so a press can scroll
    // without starting a drag.
    activation: HOLD_ACTIVATION,
    onReorder: (next) => {
        items = next
        render()
    },
})

session.subscribe(() => paint())

function bindChip(node: HTMLElement, key: string): void {
    let handle = session.registerItem(node, key)
    nodes.set(key, node)
    node.addEventListener("pointerdown", (e) => session.pointerDown(key, e))
    // later: handle.update(newKey) / handle.destroy(); nodes.delete(key)
}

function paint(): void {
    paintSortableFlowTransforms(session, nodes)
    // Overlay (if any) still uses session.getOverlayOffset() separately.
}

Activation

| Token | delayMs | When to use | | -------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | POINTER_ACTIVATION | 0 | Default. Drag starts after moveThresholdPx (10) along the session axis (1d) or euclidean distance (hypot) for createSortableBothAxis. | | HOLD_ACTIVATION | 200 | Press-and-hold. Move more than delayFailPx (4, euclidean) before the timer aborts pending so the strip can still scroll. |

Pass activation on createSortableSession or createSortableBothAxis (same tokens). pointerDown is the threshold / hold path; activate(key, clientX, clientY) arms immediately without waiting for DOM move events. Optional canDragKey is checked on pointerDown, activate, and again when the drag becomes active.

Keyboard

The session listens on document once any item is registered. Hosts set tabindex on rows (the package does not).

  • Space / Enter on a focused registered item (or a descendant) lifts it.
  • ArrowUp / ArrowLeft move one slot earlier in getItems() order; ArrowDown / ArrowRight move one later. Same map on createSortableSession and createSortableBothAxis.
  • Space / Enter drops (onReorder when the destination changed).
  • Escape cancels a pointer or keyboard drag and restores transforms.
  • Focus leaving a keyboard-grabbed item cancels.
  • Typing fields (input, textarea, select, contenteditable) skip grab / move / drop. Escape still cancels a live drag.

Auto-scroll

While dragging, the session scrolls the nearest overflow parent (or getViewport()) when the pointer enters a 60px edge zone on both axes (AUTO_SCROLL_ZONE_PX). Step size is quadratic in penetration, capped at 8px per frame (AUTO_SCROLL_MAX_PX_PER_FRAME).

  • Viewport: getViewport() override, else findScrollParent from a registered node (overflow auto / scroll / overlay and content overflowing on that axis).
  • Scroll-max is frozen at drag begin so a lifted transform cannot grow the range.
  • Math helpers computeAutoScrollDelta / computeAutoScrollDeltaX / computeAutoScrollDelta1d are exported for hosts that drive their own loop.
  • createSortableAutoScroll is exported for hosts that drive a gesture without a session (the session still constructs it internally). Hosts may pass maxStep: 18 (or autoScrollMaxPxPerFrame: 18) to keep the old cap.
  • Optional onAutoScroll(delta, viewport) runs after each applied write so a virtual list can expand its mounted window. Session option of the same name is forwarded.

Virtual lists

getItems always returns the full ordered array. Only visible rows need registerItem. Missing nodes are filled with estimateAxisSnapshots using getItemSize (or a measured size from registered nodes) so insert index and sibling shifts stay correct across unmounted keys. onReorder still receives the full reordered array.

Host adapter

The session does not paint unless the host calls paintSortableTransforms(session, nodes). That helper writes only transform / transition from getOffset (pointer delta + scroll delta for the active row; ± one item for siblings between source and insert). Pass { reduced: true } to force scale 1 and transition "none" per call when the host did not wire reducedMotion on the session. Viewport-fixed overlays should use getOverlayOffset() (pointer delta only).

Pass reducedMotion: () => boolean (stored product level, not OS MQ). session.liftScale is 1 and session.siblingTransition is "none" while that callback is true. Optional feel merges over SORTABLE_FEEL.

Reorder mode

createReorderMode() is a pure enter/exit flag with isActive and subscribe. It is not a gesture recognizer — hosts decide when drag is armed and call enter / exit. Idempotent: repeating the same state does not notify again.

import { createReorderMode } from "@yorozu/sortable"

let mode = createReorderMode()
mode.subscribe(() => renderChrome(mode.isActive))
mode.enter()
mode.exit()

Non-goals

  • Not a general drag-and-drop toolkit
  • No nested sortables
  • No grid occupancy (tetris). Wrapping flex flow is createSortableBothAxis; 1d lists stay on createSortableSession.
  • No multi-item drag
  • No domain order logic (hosts own persistence and constraints)
  • No Home/End keyboard jump and no keyboard auto-scroll