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

scroll-arrows

v0.7.0

Published

Hand-drawn arrows that draw themselves between two elements as you scroll. Roughness goes from clean straight lines to scratchy and curvy.

Readme

scroll-arrows

CI npm bundle size license

Hand-drawn arrows that draw themselves between two elements as you scroll. A single roughness knob slides from clean straight lines (0) to scratchy, curvy scribbles (1) — same sketchy engine as Excalidraw (rough.js).

Framework-agnostic core + a thin React wrapper. Arrows live in a click-through overlay <svg>, auto-track their endpoints with ResizeObserver, and draw on scroll progress.

▶ Live demo

npm install scroll-arrows

Vanilla

import { scrollArrow } from 'scroll-arrows';

const arrow = scrollArrow({
  start: '#box-a', // Element or CSS selector
  end: '#box-b',
  roughness: 0.7, // 0 clean → 1 scratchy
  stroke: '#e7e9ee',
  strokeWidth: 2.5,
  head: 'end', // "start" | "end" | "both" | "none"
  headStyle: 'solid', // "line" (open V, default) | "solid" (filled triangle)
});

// later
arrow.destroy();

How it works

  • Anchoring — pass two elements; the arrow picks the best edges (auto sockets) and recomputes when they move or resize. Override with startSocket / endSocket.

  • Scroll draw — progress is driven by a target's travel through the viewport (scroll.range, fractions of viewport height, default [0.85, 0.35]). speed finishes the stroke earlier/later; easing shapes the curve.

  • Roughness — one knob mapped onto rough.js roughness/bowing plus path curvature. seed keeps a given arrow's scribble stable across renders. Endpoints stay pinned to the anchors at any roughness (anchorEnds, default true); set it false to let scratchy ends wander off the targets.

  • Obstacle routing — pass avoid (an element or array) and the curve bows around them with an avoidPadding gap instead of cutting through. Single-bend router: it clears the worst blocker, not a full path-finder.

  • Shared-origin fan-out — when several arrows leave the same element they all resolve to the same edge point and stack. Slide each along its edge with startSocketOffset / endSocketOffset (a fraction of the edge length, 0 = centered, ±0.5 = corners) to spread them — e.g. -0.25, 0, +0.25 for three siblings off one parent.

  • Elbow routing — set route: 'elbow' for right-angle connectors (the classic tree / org-chart bracket) instead of a smooth curve. Same-axis sockets get a centered Z-bracket, perpendicular sockets a single L-corner. Pair with explicit startSocket / endSocket for predictable shapes. Elbow mode ignores avoid and curvature.

  • Hidden anchors (tabs / accordions) — an anchor inside a display:none container has no box, so the arrow can't be drawn yet. Instead of rendering a collapsed/garbage line, it draws nothing and auto-redraws the moment the anchor is revealed (via IntersectionObserver). For full control — or for engines without IntersectionObserver — call arrow.refresh() from your tab/accordion show handler:

    const arrow = scrollArrow({ start: '#a', end: '#tab-2-target' });
    
    tabButton.addEventListener('click', () => {
      showTabPanel(2); // your code reveals the panel
      arrow.refresh(); // recompute now that the anchor has a box
    });
  • Manual modescroll: false + setProgress(0..1) to drive it yourself (e.g. from GSAP/Motion).

  • Reduced motion — arrows auto-respect prefers-reduced-motion: reduce, rendering fully drawn and static (no scroll animation) while still tracking layout. Opt out with respectReducedMotion: false to keep the animation. Works the same for scrollArrowGroup.

  • Labelslabel rides along the line at labelAt — a keyword ('start' / 'middle' / 'end'), a 0..1 fraction, or a percentage string like '25%' (default 'middle') — and can sit off the line via labelOffset (perpendicular px; + = left of the draw direction, − = right). Fades in as the pen draws through it. labelBackground masks a gap in the line behind the text (the excalidraw look); style via labelColor / font.

  • Staggered groupsscrollArrowGroup owns N arrows and reveals them in sequence off one shared trigger (A then B then C). stagger (0..1) controls overlap: 1 draws each in its own slice, 0 draws them together.

Groups (staggered reveal)

For diagrams (org/family trees, flows) where a set of arrows should draw in order rather than each on its own scroll, use a group. It creates the arrows in manual mode and drives their progress as one coordinated reveal.

import { scrollArrowGroup } from 'scroll-arrows';

const group = scrollArrowGroup({
  arrows: [
    { start: '#a', end: '#b', roughness: 0.6 },
    { start: '#b', end: '#c', roughness: 0.6 },
    { start: '#b', end: '#d', roughness: 0.6 },
  ],
  stagger: 1, // 0 = all together, 1 = fully sequential (default)
  scroll: { target: '#diagram' }, // shared trigger; defaults to all endpoints
});

// later
group.destroy();

Each entry takes the usual per-arrow options. The group forces scroll: false on each arrow and slices its own progress across them. Pass scroll: false to drive the whole group yourself with group.setProgress(0..1); group.refresh() recomputes every arrow's geometry. The default scroll.target is a synthetic rect spanning every endpoint, so the group reveals as it scrolls into view.

Breakpoints / responsive

When a diagram reflows on small screens (absolute overlay → vertical stack), the arrows usually should disappear. Use setEnabled(on) to suspend and restore an arrow without tearing it down — disabling hides it and stops all scroll work; enabling shows it and recomputes geometry. Wire it to matchMedia:

const arrow = scrollArrow({ start: '#a', end: '#b' });

const mq = window.matchMedia('(max-width: 30rem)');
const sync = () => arrow.setEnabled(!mq.matches); // off below 30rem
sync();
mq.addEventListener('change', sync);

Pass enabled: false to start hidden. scrollArrowGroup has the same enabled option and setEnabled(on), toggling the whole set at once.

React

import { useRef } from 'react';
import { ScrollArrowLine } from 'scroll-arrows/react';

function Diagram() {
  const a = useRef<HTMLDivElement>(null);
  const b = useRef<HTMLDivElement>(null);
  return (
    <>
      <div ref={a}>A</div>
      <div ref={b}>B</div>
      <ScrollArrowLine start={a} end={b} roughness={0.6} />
    </>
  );
}

ScrollArrowLine renders nothing into the React tree — it manages the overlay arrow via effect and cleans up on unmount. useScrollArrow(opts) is the hook form. Pass deps={[...]} to re-create when inputs change.

For a staggered group, ScrollArrowGroupLines (and the useScrollArrowGroup hook) take an arrows array whose start/end accept refs:

<ScrollArrowGroupLines
  arrows={[
    { start: a, end: b },
    { start: b, end: c },
  ]}
  stagger={1}
/>

Astro

The core is DOM-only, so run it in a client script (it must execute in the browser, not at build time):

---
// Diagram.astro
---
<div id="a">A</div>
<div id="b">B</div>

<script>
  import { scrollArrow } from "scroll-arrows";
  scrollArrow({ start: "#a", end: "#b", roughness: 0.6 });
</script>

For an Astro React island, use the React API and hydrate with client:visible:

<Diagram client:visible />

SSR & progressive enhancement

scroll-arrows is progressive-enhancement only by design. The arrow is an overlay <svg> created by the client script at runtime — there is no server-rendered or build-time output. In SSG/SSR setups (Astro, Next, etc.) the connector simply does not exist until the script runs, so:

  • No-JS / pre-hydration users see nothing where an arrow would be. Arrows are treated as decorative enhancement, not content.
  • Don't encode meaning solely in an arrow. If a relationship must survive without JS (accessibility, SEO, no-JS fallback), express it in the DOM too — adjacent copy, a list, a caption, an aria-label — and let the arrow decorate it.
  • The library ships no static snapshot. Geometry depends on the live, laid-out positions of both anchors (and the viewport), which aren't known at build time, so a server-rendered arrow would usually be wrong anyway.

If you genuinely need a static connector in the un-hydrated state, hand-author a plain <svg> in your markup and let scroll-arrows draw over it on hydration — the runtime arrow mounts in its own overlay and won't clash with your static one.

API

scrollArrow(options) / new ScrollArrow(options) → instance with setProgress(p), refresh(), destroy().

Key options: start, end, container, roughness, stroke, strokeWidth, seed, startSocket, endSocket, startSocketOffset, endSocketOffset, curvature, route, head, headSize, headStyle, scroll, speed, easing, progress, enabled. Full types ship with the package. setEnabled(on) toggles an arrow (and a group) on/off without teardown.

Develop

npm run demo       # vite playground at /demo
npm test           # vitest (library + release tooling)
npm run coverage   # vitest + v8 coverage (pure logic gated at 90%)
npm run build      # tsup → dist (ESM + CJS + d.ts)

Releasing

Automated staging → main flow (semver, derived from conventional-commit PR titles):

  1. Feature PRs merge into staging.
  2. auto-release-pr.yml keeps a single "Release: staging to main" PR open, its body a categorized summary (Features / Fixes / Docs / Maintenance) plus the proposed next version (feat: → minor, fix:/other → patch, ! or BREAKING CHANGE → major).
  3. Merging that PR to main triggers release-changelog.yml: it computes the version from the newest v* tag, bumps package.json + prepends a CHANGELOG.md entry on a release-<version> branch, tags v<version>, and opens a sync PR back to staging.
  4. release-changelog.yml then dispatches release.yml via workflow_dispatch (a tag pushed under GITHUB_TOKEN cannot trigger on: push: tags — GitHub's anti-recursion guard), passing the tag. release.yml publishes to npm via trusted publishing (OIDC) with provenance (version pinned to the tag). A v* tag pushed manually with your own credentials also triggers release.yml directly via on: push: tags.

Release logic lives in scripts/*.mjs (pure helpers + injectable-deps orchestrators), unit-tested under vitest.

Publishing uses OIDC — no NPM_TOKEN secret. One-time bootstrap (npm requires the package to exist before a trusted publisher can be configured):

  1. npm login then npm publish --access public once from your machine.
  2. On npmjs.com → the package → Settings → Trusted Publishing, add the GitHub Actions publisher (dancj/scroll-arrows, workflow release.yml).

After that, every v* tag publishes from CI with no stored credentials.