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.
Maintainers
Readme
scroll-arrows
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.
npm install scroll-arrowsVanilla
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 (
autosockets) and recomputes when they move or resize. Override withstartSocket/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]).speedfinishes the stroke earlier/later;easingshapes the curve.Roughness — one knob mapped onto rough.js
roughness/bowingplus path curvature.seedkeeps 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 anavoidPaddinggap 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.25for 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 explicitstartSocket/endSocketfor predictable shapes. Elbow mode ignoresavoidandcurvature.Hidden anchors (tabs / accordions) — an anchor inside a
display:nonecontainer 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 (viaIntersectionObserver). For full control — or for engines withoutIntersectionObserver— callarrow.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 mode —
scroll: 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 withrespectReducedMotion: falseto keep the animation. Works the same forscrollArrowGroup.Labels —
labelrides along the line atlabelAt— a keyword ('start'/'middle'/'end'), a0..1fraction, or a percentage string like'25%'(default'middle') — and can sit off the line vialabelOffset(perpendicular px; + = left of the draw direction, − = right). Fades in as the pen draws through it.labelBackgroundmasks a gap in the line behind the text (the excalidraw look); style vialabelColor/font.Staggered groups —
scrollArrowGroupowns N arrows and reveals them in sequence off one shared trigger (A then B then C).stagger(0..1) controls overlap:1draws each in its own slice,0draws 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):
- Feature PRs merge into
staging. auto-release-pr.ymlkeeps 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,!orBREAKING CHANGE→ major).- Merging that PR to
maintriggersrelease-changelog.yml: it computes the version from the newestv*tag, bumpspackage.json+ prepends aCHANGELOG.mdentry on arelease-<version>branch, tagsv<version>, and opens a sync PR back tostaging. release-changelog.ymlthen dispatchesrelease.ymlviaworkflow_dispatch(a tag pushed underGITHUB_TOKENcannot triggeron: push: tags— GitHub's anti-recursion guard), passing the tag.release.ymlpublishes to npm via trusted publishing (OIDC) with provenance (version pinned to the tag). Av*tag pushed manually with your own credentials also triggersrelease.ymldirectly viaon: 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):
npm loginthennpm publish --access publiconce from your machine.- On npmjs.com → the package → Settings → Trusted Publishing, add the
GitHub Actions publisher (
dancj/scroll-arrows, workflowrelease.yml).
After that, every v* tag publishes from CI with no stored credentials.
