scroll-arrows 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,8 +2,15 @@
2
2
 
3
3
  [![CI](https://github.com/dancj/scroll-arrows/actions/workflows/ci.yml/badge.svg)](https://github.com/dancj/scroll-arrows/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/scroll-arrows.svg)](https://www.npmjs.com/package/scroll-arrows)
5
- [![bundle size](https://img.shields.io/bundlephobia/minzip/scroll-arrows)](https://bundlephobia.com/package/scroll-arrows)
6
- [![license](https://img.shields.io/npm/l/scroll-arrows.svg)](./LICENSE)
5
+ [![bundle size](https://img.shields.io/bundlejs/size/scroll-arrows)](https://bundlejs.com/?q=scroll-arrows)
6
+ [![license](https://img.shields.io/github/license/dancj/scroll-arrows.svg)](./LICENSE)
7
+
8
+ <p align="center">
9
+ <picture>
10
+ <source media="(prefers-color-scheme: dark)" srcset="./assets/hero-arrow-dark.svg" />
11
+ <img alt="A hand-drawn arrow drawing itself" src="./assets/hero-arrow-light.svg" width="640" />
12
+ </picture>
13
+ </p>
7
14
 
8
15
  Hand-drawn arrows that **draw themselves between two elements as you scroll**.
9
16
  A single `roughness` knob slides from clean straight lines (0) to scratchy,
@@ -20,15 +27,15 @@ npm install scroll-arrows
20
27
  ## Vanilla
21
28
 
22
29
  ```ts
23
- import { scrollArrow } from "scroll-arrows";
30
+ import { scrollArrow } from 'scroll-arrows';
24
31
 
25
32
  const arrow = scrollArrow({
26
- start: "#box-a", // Element or CSS selector
27
- end: "#box-b",
28
- roughness: 0.7, // 0 clean → 1 scratchy
29
- stroke: "#e7e9ee",
33
+ start: '#box-a', // Element or CSS selector
34
+ end: '#box-b',
35
+ roughness: 0.7, // 0 clean → 1 scratchy
36
+ stroke: '#e7e9ee',
30
37
  strokeWidth: 2.5,
31
- head: "end", // "start" | "end" | "both" | "none"
38
+ head: 'end', // "start" | "end" | "both" | "none"
32
39
  });
33
40
 
34
41
  // later
@@ -50,19 +57,100 @@ arrow.destroy();
50
57
  - **Obstacle routing** — pass `avoid` (an element or array) and the curve bows
51
58
  around them with an `avoidPadding` gap instead of cutting through. Single-bend
52
59
  router: it clears the worst blocker, not a full path-finder.
60
+ - **Shared-origin fan-out** — when several arrows leave the same element they all
61
+ resolve to the same edge point and stack. Slide each along its edge with
62
+ `startSocketOffset` / `endSocketOffset` (a fraction of the edge length, `0` =
63
+ centered, `±0.5` = corners) to spread them — e.g. `-0.25`, `0`, `+0.25` for
64
+ three siblings off one parent.
65
+ - **Elbow routing** — set `route: 'elbow'` for right-angle connectors (the
66
+ classic tree / org-chart bracket) instead of a smooth curve. Same-axis sockets
67
+ get a centered Z-bracket, perpendicular sockets a single L-corner. Pair with
68
+ explicit `startSocket` / `endSocket` for predictable shapes. Elbow mode ignores
69
+ `avoid` and `curvature`.
70
+ - **Hidden anchors (tabs / accordions)** — an anchor inside a `display:none`
71
+ container has no box, so the arrow can't be drawn yet. Instead of rendering a
72
+ collapsed/garbage line, it draws nothing and **auto-redraws the moment the
73
+ anchor is revealed** (via `IntersectionObserver`). For full control — or for
74
+ engines without `IntersectionObserver` — call `arrow.refresh()` from your
75
+ tab/accordion show handler:
76
+
77
+ ```ts
78
+ const arrow = scrollArrow({ start: '#a', end: '#tab-2-target' });
79
+
80
+ tabButton.addEventListener('click', () => {
81
+ showTabPanel(2); // your code reveals the panel
82
+ arrow.refresh(); // recompute now that the anchor has a box
83
+ });
84
+ ```
85
+
53
86
  - **Manual mode** — `scroll: false` + `setProgress(0..1)` to drive it yourself
54
87
  (e.g. from GSAP/Motion).
88
+ - **Reduced motion** — arrows auto-respect `prefers-reduced-motion: reduce`,
89
+ rendering fully drawn and static (no scroll animation) while still tracking
90
+ layout. Opt out with `respectReducedMotion: false` to keep the animation.
91
+ Works the same for `scrollArrowGroup`.
55
92
  - **Labels** — `label` rides along the line at `labelAt` (0..1, default mid)
56
93
  and can sit off the line via `labelOffset` (perpendicular px; + = left of the
57
94
  draw direction, − = right). Fades in as the pen draws through it.
58
95
  `labelBackground` masks a gap in the line behind the text (the excalidraw
59
96
  look); style via `labelColor` / `font`.
97
+ - **Staggered groups** — `scrollArrowGroup` owns N arrows and reveals them in
98
+ sequence off one shared trigger (`A then B then C`). `stagger` (0..1) controls
99
+ overlap: `1` draws each in its own slice, `0` draws them together.
100
+
101
+ ## Groups (staggered reveal)
102
+
103
+ For diagrams (org/family trees, flows) where a set of arrows should draw in
104
+ order rather than each on its own scroll, use a group. It creates the arrows in
105
+ manual mode and drives their progress as one coordinated reveal.
106
+
107
+ ```ts
108
+ import { scrollArrowGroup } from 'scroll-arrows';
109
+
110
+ const group = scrollArrowGroup({
111
+ arrows: [
112
+ { start: '#a', end: '#b', roughness: 0.6 },
113
+ { start: '#b', end: '#c', roughness: 0.6 },
114
+ { start: '#b', end: '#d', roughness: 0.6 },
115
+ ],
116
+ stagger: 1, // 0 = all together, 1 = fully sequential (default)
117
+ scroll: { target: '#diagram' }, // shared trigger; defaults to all endpoints
118
+ });
119
+
120
+ // later
121
+ group.destroy();
122
+ ```
123
+
124
+ Each entry takes the usual per-arrow options. The group forces `scroll: false`
125
+ on each arrow and slices its own progress across them. Pass `scroll: false` to
126
+ drive the whole group yourself with `group.setProgress(0..1)`; `group.refresh()`
127
+ recomputes every arrow's geometry. The default `scroll.target` is a synthetic
128
+ rect spanning every endpoint, so the group reveals as it scrolls into view.
129
+
130
+ ## Breakpoints / responsive
131
+
132
+ When a diagram reflows on small screens (absolute overlay → vertical stack), the
133
+ arrows usually should disappear. Use `setEnabled(on)` to suspend and restore an
134
+ arrow **without tearing it down** — disabling hides it and stops all scroll work;
135
+ enabling shows it and recomputes geometry. Wire it to `matchMedia`:
136
+
137
+ ```ts
138
+ const arrow = scrollArrow({ start: '#a', end: '#b' });
139
+
140
+ const mq = window.matchMedia('(max-width: 30rem)');
141
+ const sync = () => arrow.setEnabled(!mq.matches); // off below 30rem
142
+ sync();
143
+ mq.addEventListener('change', sync);
144
+ ```
145
+
146
+ Pass `enabled: false` to start hidden. `scrollArrowGroup` has the same
147
+ `enabled` option and `setEnabled(on)`, toggling the whole set at once.
60
148
 
61
149
  ## React
62
150
 
63
151
  ```tsx
64
- import { useRef } from "react";
65
- import { ScrollArrowLine } from "scroll-arrows/react";
152
+ import { useRef } from 'react';
153
+ import { ScrollArrowLine } from 'scroll-arrows/react';
66
154
 
67
155
  function Diagram() {
68
156
  const a = useRef<HTMLDivElement>(null);
@@ -81,6 +169,19 @@ function Diagram() {
81
169
  arrow via effect and cleans up on unmount. `useScrollArrow(opts)` is the hook
82
170
  form. Pass `deps={[...]}` to re-create when inputs change.
83
171
 
172
+ For a staggered group, `ScrollArrowGroupLines` (and the `useScrollArrowGroup`
173
+ hook) take an `arrows` array whose `start`/`end` accept refs:
174
+
175
+ ```tsx
176
+ <ScrollArrowGroupLines
177
+ arrows={[
178
+ { start: a, end: b },
179
+ { start: b, end: c },
180
+ ]}
181
+ stagger={1}
182
+ />
183
+ ```
184
+
84
185
  ## Astro
85
186
 
86
187
  The core is DOM-only, so run it in a client script (it must execute in the
@@ -105,14 +206,36 @@ For an Astro React island, use the React API and hydrate with `client:visible`:
105
206
  <Diagram client:visible />
106
207
  ```
107
208
 
209
+ ## SSR & progressive enhancement
210
+
211
+ scroll-arrows is **progressive-enhancement only by design**. The arrow is an
212
+ overlay `<svg>` created by the client script at runtime — there is no
213
+ server-rendered or build-time output. In SSG/SSR setups (Astro, Next, etc.) the
214
+ connector simply does not exist until the script runs, so:
215
+
216
+ - **No-JS / pre-hydration users see nothing** where an arrow would be. Arrows
217
+ are treated as decorative enhancement, not content.
218
+ - Don't encode meaning solely in an arrow. If a relationship must survive without
219
+ JS (accessibility, SEO, no-JS fallback), express it in the DOM too — adjacent
220
+ copy, a list, a caption, an `aria-label` — and let the arrow decorate it.
221
+ - The library ships no static snapshot. Geometry depends on the live, laid-out
222
+ positions of both anchors (and the viewport), which aren't known at build time,
223
+ so a server-rendered arrow would usually be wrong anyway.
224
+
225
+ If you genuinely need a static connector in the un-hydrated state, hand-author a
226
+ plain `<svg>` in your markup and let scroll-arrows draw over it on hydration —
227
+ the runtime arrow mounts in its own overlay and won't clash with your static one.
228
+
108
229
  ## API
109
230
 
110
231
  `scrollArrow(options)` / `new ScrollArrow(options)` → instance with
111
232
  `setProgress(p)`, `refresh()`, `destroy()`.
112
233
 
113
234
  Key options: `start`, `end`, `container`, `roughness`, `stroke`, `strokeWidth`,
114
- `seed`, `startSocket`, `endSocket`, `curvature`, `head`, `headSize`, `scroll`,
115
- `speed`, `easing`, `progress`. Full types ship with the package.
235
+ `seed`, `startSocket`, `endSocket`, `startSocketOffset`, `endSocketOffset`,
236
+ `curvature`, `route`, `head`, `headSize`, `scroll`, `speed`, `easing`,
237
+ `progress`, `enabled`. Full types ship with the package. `setEnabled(on)` toggles
238
+ an arrow (and a group) on/off without teardown.
116
239
 
117
240
  ## Develop
118
241
 
@@ -137,8 +260,12 @@ titles):
137
260
  version from the newest `v*` tag, bumps `package.json` + prepends a
138
261
  `CHANGELOG.md` entry on a `release-<version>` branch, tags `v<version>`, and
139
262
  opens a sync PR back to `staging`.
140
- 4. The pushed `v*` tag triggers `release.yml`, publishing to npm via
141
- **trusted publishing (OIDC)** with provenance (version pinned to the tag).
263
+ 4. `release-changelog.yml` then dispatches `release.yml` via `workflow_dispatch`
264
+ (a tag pushed under `GITHUB_TOKEN` cannot trigger `on: push: tags` — GitHub's
265
+ anti-recursion guard), passing the tag. `release.yml` publishes to npm via
266
+ **trusted publishing (OIDC)** with provenance (version pinned to the tag). A
267
+ `v*` tag pushed manually with your own credentials also triggers `release.yml`
268
+ directly via `on: push: tags`.
142
269
 
143
270
  Release logic lives in `scripts/*.mjs` (pure helpers + injectable-deps
144
271
  orchestrators), unit-tested under vitest.
@@ -16,20 +16,24 @@ function docRect(el) {
16
16
  height: r2.height
17
17
  };
18
18
  }
19
+ function isDegenerateRect(r2) {
20
+ return r2.width <= 0 || r2.height <= 0;
21
+ }
19
22
  function center(r2) {
20
23
  return { x: r2.left + r2.width / 2, y: r2.top + r2.height / 2 };
21
24
  }
22
- function socketPoint(r2, side) {
25
+ function socketPoint(r2, side, offset = 0) {
23
26
  const c = center(r2);
27
+ const o = offset < -0.5 ? -0.5 : offset > 0.5 ? 0.5 : offset;
24
28
  switch (side) {
25
29
  case "top":
26
- return { x: c.x, y: r2.top };
30
+ return { x: c.x + o * r2.width, y: r2.top };
27
31
  case "bottom":
28
- return { x: c.x, y: r2.top + r2.height };
32
+ return { x: c.x + o * r2.width, y: r2.top + r2.height };
29
33
  case "left":
30
- return { x: r2.left, y: c.y };
34
+ return { x: r2.left, y: c.y + o * r2.height };
31
35
  case "right":
32
- return { x: r2.left + r2.width, y: c.y };
36
+ return { x: r2.left + r2.width, y: c.y + o * r2.height };
33
37
  default:
34
38
  return c;
35
39
  }
@@ -62,12 +66,12 @@ function autoSide(self, other) {
62
66
  }
63
67
  return best;
64
68
  }
65
- function resolveEndpoints(startRect, endRect, startSocket, endSocket) {
69
+ function resolveEndpoints(startRect, endRect, startSocket, endSocket, startOffset = 0, endOffset = 0) {
66
70
  const s = startSocket === "auto" ? autoSide(startRect, endRect) : startSocket;
67
71
  const e = endSocket === "auto" ? autoSide(endRect, startRect) : endSocket;
68
72
  return {
69
- start: socketPoint(startRect, s),
70
- end: socketPoint(endRect, e),
73
+ start: socketPoint(startRect, s, startOffset),
74
+ end: socketPoint(endRect, e, endOffset),
71
75
  startNormal: socketNormal(s),
72
76
  endNormal: socketNormal(e)
73
77
  };
@@ -80,10 +84,36 @@ function buildPath(ep, curvature, belly = { x: 0, y: 0 }) {
80
84
  const reach = dist * (0.3 + curvature * 0.4);
81
85
  const sn = startNormal.x || startNormal.y ? startNormal : unit(dx, dy);
82
86
  const en = endNormal.x || endNormal.y ? endNormal : unit(-dx, -dy);
83
- const c1 = { x: start.x + sn.x * reach + belly.x, y: start.y + sn.y * reach + belly.y };
84
- const c2 = { x: end.x + en.x * reach + belly.x, y: end.y + en.y * reach + belly.y };
87
+ const c1 = {
88
+ x: start.x + sn.x * reach + belly.x,
89
+ y: start.y + sn.y * reach + belly.y
90
+ };
91
+ const c2 = {
92
+ x: end.x + en.x * reach + belly.x,
93
+ y: end.y + en.y * reach + belly.y
94
+ };
85
95
  return `M ${r(start.x)} ${r(start.y)} C ${r(c1.x)} ${r(c1.y)} ${r(c2.x)} ${r(c2.y)} ${r(end.x)} ${r(end.y)}`;
86
96
  }
97
+ function buildElbowPath(ep) {
98
+ const { start: s, end: e, startNormal: sn, endNormal: en } = ep;
99
+ const dx = e.x - s.x;
100
+ const dy = e.y - s.y;
101
+ const startVertical = sn.y !== 0 || sn.x === 0 && Math.abs(dy) >= Math.abs(dx);
102
+ const endVertical = en.y !== 0 || en.x === 0 && Math.abs(dy) >= Math.abs(dx);
103
+ let pts;
104
+ if (startVertical && endVertical) {
105
+ const midY = (s.y + e.y) / 2;
106
+ pts = [s, { x: s.x, y: midY }, { x: e.x, y: midY }, e];
107
+ } else if (!startVertical && !endVertical) {
108
+ const midX = (s.x + e.x) / 2;
109
+ pts = [s, { x: midX, y: s.y }, { x: midX, y: e.y }, e];
110
+ } else if (startVertical) {
111
+ pts = [s, { x: s.x, y: e.y }, e];
112
+ } else {
113
+ pts = [s, { x: e.x, y: s.y }, e];
114
+ }
115
+ return `M ${r(pts[0].x)} ${r(pts[0].y)}` + pts.slice(1).map((p) => ` L ${r(p.x)} ${r(p.y)}`).join("");
116
+ }
87
117
  function routeOffset(start, end, obstacles, padding = 14) {
88
118
  const dx = end.x - start.x;
89
119
  const dy = end.y - start.y;
@@ -159,12 +189,29 @@ function easeInOutCubic(t) {
159
189
  function clamp01(t) {
160
190
  return t < 0 ? 0 : t > 1 ? 1 : t;
161
191
  }
192
+ function prefersReducedMotion() {
193
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
194
+ return false;
195
+ }
196
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
197
+ }
162
198
  function scrollProgress(targetRect, range) {
163
199
  const vh = window.innerHeight || 1;
164
200
  const topFrac = (targetRect.top - window.scrollY) / vh;
165
201
  const [enter, leave] = range;
166
202
  return clamp01((enter - topFrac) / (enter - leave || 1));
167
203
  }
204
+ function staggerWindows(n, stagger) {
205
+ if (n <= 0) return [];
206
+ const s = clamp01(stagger);
207
+ const span = 1 / (1 + (n - 1) * s);
208
+ const step = span * s;
209
+ return Array.from({ length: n }, (_, i) => ({ start: i * step, span }));
210
+ }
211
+ function windowProgress(p, w) {
212
+ if (w.span <= 0) return p > w.start ? 1 : 0;
213
+ return clamp01((p - w.start) / w.span);
214
+ }
168
215
  function midpointRect(a, b) {
169
216
  const ra = docRect(a);
170
217
  const rb = docRect(b);
@@ -201,7 +248,8 @@ function getOverlay(container) {
201
248
  (_a = document.body.style).position || (_a.position = "relative");
202
249
  } else {
203
250
  const pos = getComputedStyle(container).position;
204
- if (pos === "static") container.style.position = "relative";
251
+ if (pos === "static")
252
+ container.style.position = "relative";
205
253
  }
206
254
  container.appendChild(svg);
207
255
  overlays.set(container, svg);
@@ -321,14 +369,36 @@ var ScrollArrow = class {
321
369
  this.svg = getOverlay(this.container);
322
370
  this.rc = rough.svg(this.svg);
323
371
  this.svg.appendChild(this.group);
324
- this.progress = clamp01(this.opts.progress);
372
+ this.reducedMotion = this.opts.respectReducedMotion !== false && prefersReducedMotion();
373
+ this.progress = this.reducedMotion ? 1 : clamp01(this.opts.progress);
325
374
  this.refs = this.resolveRefs();
326
375
  this.stroke = options.stroke ?? getComputedStyle(this.container).color ?? "#222";
327
376
  this.seed = options.seed ?? deriveSeed(refKey(options.start), refKey(options.end));
377
+ this.enabled = this.opts.enabled ?? true;
378
+ if (!this.enabled) this.group.style.display = "none";
328
379
  this.render();
329
380
  this.bind();
330
381
  this.update();
331
382
  }
383
+ /**
384
+ * Suspend or restore the arrow without tearing it down. Disabling hides it and
385
+ * stops all draw/scroll work; enabling shows it and recomputes geometry (so it
386
+ * reflects any layout change that happened while hidden). Idempotent. Wire it
387
+ * to `matchMedia` to switch arrows off below a breakpoint.
388
+ */
389
+ setEnabled(on) {
390
+ if (on === this.enabled || this.destroyed) return;
391
+ this.enabled = on;
392
+ if (on) {
393
+ this.group.style.display = "";
394
+ this.render();
395
+ this.update();
396
+ } else {
397
+ this.group.style.display = "none";
398
+ cancelAnimationFrame(this.rafId);
399
+ this.rafId = 0;
400
+ }
401
+ }
332
402
  /** Manually set draw progress (0..1). Only meaningful when scroll is false. */
333
403
  setProgress(p) {
334
404
  this.progress = clamp01(p);
@@ -342,6 +412,7 @@ var ScrollArrow = class {
342
412
  destroy() {
343
413
  this.destroyed = true;
344
414
  this.ro?.disconnect();
415
+ this.teardownReveal();
345
416
  window.removeEventListener("scroll", this.onScroll, true);
346
417
  window.removeEventListener("resize", this.onScroll);
347
418
  cancelAnimationFrame(this.rafId);
@@ -367,17 +438,30 @@ var ScrollArrow = class {
367
438
  const list = Array.isArray(a) ? a : [a];
368
439
  return list.map(resolve).filter((el) => el !== null);
369
440
  }
370
- computeEndpoints() {
371
- const sr = docRect(this.refs.start);
372
- const er = docRect(this.refs.end);
441
+ computeEndpoints(sr, er) {
373
442
  const ss = this.opts.startSocket ?? "auto";
374
443
  const es = this.opts.endSocket ?? "auto";
375
- return resolveEndpoints(sr, er, ss, es);
444
+ return resolveEndpoints(
445
+ sr,
446
+ er,
447
+ ss,
448
+ es,
449
+ this.opts.startSocketOffset ?? 0,
450
+ this.opts.endSocketOffset ?? 0
451
+ );
376
452
  }
377
453
  render() {
378
454
  while (this.group.firstChild) this.group.removeChild(this.group.firstChild);
379
455
  this.segments = [];
380
- const ep = this.computeEndpoints();
456
+ if (!this.enabled) return;
457
+ const sr = docRect(this.refs.start);
458
+ const er = docRect(this.refs.end);
459
+ if (isDegenerateRect(sr) || isDegenerateRect(er)) {
460
+ this.armReveal();
461
+ return;
462
+ }
463
+ this.teardownReveal();
464
+ const ep = this.computeEndpoints(sr, er);
381
465
  const origin = overlayOrigin(this.svg);
382
466
  const shift = (e) => ({
383
467
  start: { x: e.start.x - origin.x, y: e.start.y - origin.y },
@@ -394,24 +478,29 @@ var ScrollArrow = class {
394
478
  this.seed,
395
479
  this.opts.anchorEnds ?? true
396
480
  );
397
- const obstacles = this.resolveAvoid().map((el) => {
398
- const dr = docRect(el);
399
- return {
400
- left: dr.left - origin.x,
401
- top: dr.top - origin.y,
402
- width: dr.width,
403
- height: dr.height
404
- };
405
- });
406
- const clear = routeOffset(
407
- local.start,
408
- local.end,
409
- obstacles,
410
- this.opts.avoidPadding ?? 14
411
- );
412
- const BOW = 1.6;
413
- const belly = { x: clear.x * BOW, y: clear.y * BOW };
414
- const d = buildPath(local, curvature, belly);
481
+ let d;
482
+ if (this.opts.route === "elbow") {
483
+ d = buildElbowPath(local);
484
+ } else {
485
+ const obstacles = this.resolveAvoid().map((el) => {
486
+ const dr = docRect(el);
487
+ return {
488
+ left: dr.left - origin.x,
489
+ top: dr.top - origin.y,
490
+ width: dr.width,
491
+ height: dr.height
492
+ };
493
+ });
494
+ const clear = routeOffset(
495
+ local.start,
496
+ local.end,
497
+ obstacles,
498
+ this.opts.avoidPadding ?? 14
499
+ );
500
+ const BOW = 1.6;
501
+ const belly = { x: clear.x * BOW, y: clear.y * BOW };
502
+ d = buildPath(local, curvature, belly);
503
+ }
415
504
  this.appendDrawable(this.rc.path(d, roughOpts), "line");
416
505
  const head = this.opts.head;
417
506
  const size = this.opts.headSize;
@@ -457,8 +546,12 @@ var ScrollArrow = class {
457
546
  let y = pt.y;
458
547
  if (offset && total > 0) {
459
548
  const eps = Math.min(1, total / 2);
460
- const before = this.lineEl.getPointAtLength(Math.max(0, at * total - eps));
461
- const after = this.lineEl.getPointAtLength(Math.min(total, at * total + eps));
549
+ const before = this.lineEl.getPointAtLength(
550
+ Math.max(0, at * total - eps)
551
+ );
552
+ const after = this.lineEl.getPointAtLength(
553
+ Math.min(total, at * total + eps)
554
+ );
462
555
  const n = unitNormal(before, after);
463
556
  x += n.x * offset;
464
557
  y += n.y * offset;
@@ -518,19 +611,37 @@ var ScrollArrow = class {
518
611
  if (this.labelBgEl) this.labelBgEl.style.opacity = String(op);
519
612
  }
520
613
  }
614
+ /**
615
+ * Watch the anchors for the moment a hidden one becomes laid out, then redraw.
616
+ * IntersectionObserver fires when a previously `display:none` element gains a
617
+ * box — a transition ResizeObserver does not reliably report. Armed lazily and
618
+ * idempotently; torn down by the next successful render(). Guarded so it no-ops
619
+ * in environments without IntersectionObserver (older engines, some SSR/jsdom).
620
+ */
621
+ armReveal() {
622
+ if (this.revealObs || this.destroyed) return;
623
+ if (typeof IntersectionObserver === "undefined") return;
624
+ this.revealObs = new IntersectionObserver(() => this.render());
625
+ this.revealObs.observe(this.refs.start);
626
+ this.revealObs.observe(this.refs.end);
627
+ }
628
+ teardownReveal() {
629
+ this.revealObs?.disconnect();
630
+ this.revealObs = void 0;
631
+ }
521
632
  bind() {
522
633
  const targets = [this.refs.start, this.refs.end, ...this.resolveAvoid()];
523
634
  if (this.refs.target) targets.push(this.refs.target);
524
635
  this.ro = new ResizeObserver(() => this.render());
525
636
  targets.forEach((t) => this.ro.observe(t));
526
- if (this.opts.scroll !== false) {
637
+ if (this.opts.scroll !== false && !this.reducedMotion) {
527
638
  window.addEventListener("scroll", this.onScroll, true);
528
639
  window.addEventListener("resize", this.onScroll);
529
640
  }
530
641
  }
531
642
  update() {
532
- if (this.destroyed) return;
533
- if (this.opts.scroll === false) {
643
+ if (this.destroyed || !this.enabled) return;
644
+ if (this.opts.scroll === false || this.reducedMotion) {
534
645
  this.applyProgress();
535
646
  return;
536
647
  }
@@ -552,6 +663,140 @@ function clampAt(t) {
552
663
  return t < 0 ? 0 : t > 1 ? 1 : t;
553
664
  }
554
665
 
555
- export { ScrollArrow, easeInOutCubic };
556
- //# sourceMappingURL=chunk-HLZXSGP5.js.map
557
- //# sourceMappingURL=chunk-HLZXSGP5.js.map
666
+ // src/group.ts
667
+ var ScrollArrowGroup = class {
668
+ constructor(options) {
669
+ this.elements = [];
670
+ this.target = null;
671
+ this.progress = 0;
672
+ this.rafId = 0;
673
+ this.destroyed = false;
674
+ this.onScroll = () => {
675
+ if (this.rafId) return;
676
+ this.rafId = requestAnimationFrame(() => {
677
+ this.rafId = 0;
678
+ this.update();
679
+ });
680
+ };
681
+ if (!options.arrows || options.arrows.length === 0) {
682
+ throw new Error("[scroll-arrows] group needs at least one arrow");
683
+ }
684
+ this.opts = {
685
+ stagger: 1,
686
+ speed: 1,
687
+ easing: (t) => t,
688
+ ...options
689
+ };
690
+ this.reducedMotion = this.opts.respectReducedMotion !== false && prefersReducedMotion();
691
+ this.enabled = this.opts.enabled ?? true;
692
+ this.arrows = options.arrows.map(
693
+ (a) => new ScrollArrow({
694
+ ...a,
695
+ scroll: false,
696
+ progress: 0,
697
+ respectReducedMotion: false,
698
+ enabled: this.enabled
699
+ })
700
+ );
701
+ this.windows = staggerWindows(this.arrows.length, this.opts.stagger);
702
+ for (const a of options.arrows) {
703
+ const s = resolve2(a.start);
704
+ const e = resolve2(a.end);
705
+ if (s) this.elements.push(s);
706
+ if (e) this.elements.push(e);
707
+ }
708
+ const scroll = this.opts.scroll;
709
+ if (scroll !== false && scroll?.target) {
710
+ this.target = resolve2(scroll.target);
711
+ }
712
+ if (this.reducedMotion) this.progress = 1;
713
+ this.bind();
714
+ this.update();
715
+ }
716
+ /** Manually set group progress (0..1). Only meaningful when scroll is false. */
717
+ setProgress(p) {
718
+ this.progress = clamp01(p);
719
+ this.distribute();
720
+ }
721
+ /** Recompute geometry for every arrow (call after layout changes). */
722
+ refresh() {
723
+ this.arrows.forEach((a) => a.refresh());
724
+ this.update();
725
+ }
726
+ /**
727
+ * Suspend or restore the whole group without tearing it down. Hides every
728
+ * arrow and stops scroll work when off; restores and recomputes when on.
729
+ * Idempotent. Wire to `matchMedia` for breakpoint-aware diagrams.
730
+ */
731
+ setEnabled(on) {
732
+ if (on === this.enabled || this.destroyed) return;
733
+ this.enabled = on;
734
+ this.arrows.forEach((a) => a.setEnabled(on));
735
+ if (on) {
736
+ this.bind();
737
+ this.update();
738
+ } else {
739
+ window.removeEventListener("scroll", this.onScroll, true);
740
+ window.removeEventListener("resize", this.onScroll);
741
+ cancelAnimationFrame(this.rafId);
742
+ this.rafId = 0;
743
+ }
744
+ }
745
+ destroy() {
746
+ this.destroyed = true;
747
+ window.removeEventListener("scroll", this.onScroll, true);
748
+ window.removeEventListener("resize", this.onScroll);
749
+ cancelAnimationFrame(this.rafId);
750
+ this.arrows.forEach((a) => a.destroy());
751
+ }
752
+ // --- internals ---------------------------------------------------------
753
+ bind() {
754
+ if (this.opts.scroll === false || this.reducedMotion || !this.enabled)
755
+ return;
756
+ window.addEventListener("scroll", this.onScroll, true);
757
+ window.addEventListener("resize", this.onScroll);
758
+ }
759
+ update() {
760
+ if (this.destroyed || !this.enabled) return;
761
+ if (this.opts.scroll === false || this.reducedMotion) {
762
+ this.distribute();
763
+ return;
764
+ }
765
+ const scroll = this.opts.scroll ?? {};
766
+ const range = scroll.range ?? [0.85, 0.35];
767
+ const rect = this.target ? docRect(this.target) : this.groupRect();
768
+ const raw = scrollProgress(rect, range);
769
+ this.progress = clamp01(raw * this.opts.speed);
770
+ this.distribute();
771
+ }
772
+ /** Push each arrow to its sliced local progress for the current group value. */
773
+ distribute() {
774
+ const eased = clamp01(this.opts.easing(this.progress));
775
+ this.arrows.forEach((arrow, i) => {
776
+ arrow.setProgress(windowProgress(eased, this.windows[i]));
777
+ });
778
+ }
779
+ /** Synthetic rect spanning every endpoint, used as the default trigger. */
780
+ groupRect() {
781
+ const rects = this.elements.map(docRect);
782
+ if (rects.length === 0) return { left: 0, top: 0, width: 0, height: 0 };
783
+ let left = Infinity;
784
+ let top = Infinity;
785
+ let right = -Infinity;
786
+ let bottom = -Infinity;
787
+ for (const r2 of rects) {
788
+ left = Math.min(left, r2.left);
789
+ top = Math.min(top, r2.top);
790
+ right = Math.max(right, r2.left + r2.width);
791
+ bottom = Math.max(bottom, r2.top + r2.height);
792
+ }
793
+ return { left, top, width: right - left, height: bottom - top };
794
+ }
795
+ };
796
+ function resolve2(ref) {
797
+ return typeof ref === "string" ? document.querySelector(ref) : ref;
798
+ }
799
+
800
+ export { ScrollArrow, ScrollArrowGroup, easeInOutCubic };
801
+ //# sourceMappingURL=chunk-LIT577GH.js.map
802
+ //# sourceMappingURL=chunk-LIT577GH.js.map