scroll-arrows 0.1.3 → 0.3.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
@@ -57,13 +57,96 @@ arrow.destroy();
57
57
  - **Obstacle routing** — pass `avoid` (an element or array) and the curve bows
58
58
  around them with an `avoidPadding` gap instead of cutting through. Single-bend
59
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
+
60
86
  - **Manual mode** — `scroll: false` + `setProgress(0..1)` to drive it yourself
61
87
  (e.g. from GSAP/Motion).
62
- - **Labels** — `label` rides along the line at `labelAt` (0..1, default mid)
63
- and can sit off the line via `labelOffset` (perpendicular px; + = left of the
64
- draw direction, = right). Fades in as the pen draws through it.
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`.
92
+ - **Labels** — `label` rides along the line at `labelAt` — a keyword
93
+ (`'start'` / `'middle'` / `'end'`), a `0..1` fraction, or a percentage string
94
+ like `'25%'` (default `'middle'`) — and can sit off the line via `labelOffset`
95
+ (perpendicular px; + = left of the draw direction, − = right). Fades in as the
96
+ pen draws through it.
65
97
  `labelBackground` masks a gap in the line behind the text (the excalidraw
66
98
  look); style via `labelColor` / `font`.
99
+ - **Staggered groups** — `scrollArrowGroup` owns N arrows and reveals them in
100
+ sequence off one shared trigger (`A then B then C`). `stagger` (0..1) controls
101
+ overlap: `1` draws each in its own slice, `0` draws them together.
102
+
103
+ ## Groups (staggered reveal)
104
+
105
+ For diagrams (org/family trees, flows) where a set of arrows should draw in
106
+ order rather than each on its own scroll, use a group. It creates the arrows in
107
+ manual mode and drives their progress as one coordinated reveal.
108
+
109
+ ```ts
110
+ import { scrollArrowGroup } from 'scroll-arrows';
111
+
112
+ const group = scrollArrowGroup({
113
+ arrows: [
114
+ { start: '#a', end: '#b', roughness: 0.6 },
115
+ { start: '#b', end: '#c', roughness: 0.6 },
116
+ { start: '#b', end: '#d', roughness: 0.6 },
117
+ ],
118
+ stagger: 1, // 0 = all together, 1 = fully sequential (default)
119
+ scroll: { target: '#diagram' }, // shared trigger; defaults to all endpoints
120
+ });
121
+
122
+ // later
123
+ group.destroy();
124
+ ```
125
+
126
+ Each entry takes the usual per-arrow options. The group forces `scroll: false`
127
+ on each arrow and slices its own progress across them. Pass `scroll: false` to
128
+ drive the whole group yourself with `group.setProgress(0..1)`; `group.refresh()`
129
+ recomputes every arrow's geometry. The default `scroll.target` is a synthetic
130
+ rect spanning every endpoint, so the group reveals as it scrolls into view.
131
+
132
+ ## Breakpoints / responsive
133
+
134
+ When a diagram reflows on small screens (absolute overlay → vertical stack), the
135
+ arrows usually should disappear. Use `setEnabled(on)` to suspend and restore an
136
+ arrow **without tearing it down** — disabling hides it and stops all scroll work;
137
+ enabling shows it and recomputes geometry. Wire it to `matchMedia`:
138
+
139
+ ```ts
140
+ const arrow = scrollArrow({ start: '#a', end: '#b' });
141
+
142
+ const mq = window.matchMedia('(max-width: 30rem)');
143
+ const sync = () => arrow.setEnabled(!mq.matches); // off below 30rem
144
+ sync();
145
+ mq.addEventListener('change', sync);
146
+ ```
147
+
148
+ Pass `enabled: false` to start hidden. `scrollArrowGroup` has the same
149
+ `enabled` option and `setEnabled(on)`, toggling the whole set at once.
67
150
 
68
151
  ## React
69
152
 
@@ -88,6 +171,19 @@ function Diagram() {
88
171
  arrow via effect and cleans up on unmount. `useScrollArrow(opts)` is the hook
89
172
  form. Pass `deps={[...]}` to re-create when inputs change.
90
173
 
174
+ For a staggered group, `ScrollArrowGroupLines` (and the `useScrollArrowGroup`
175
+ hook) take an `arrows` array whose `start`/`end` accept refs:
176
+
177
+ ```tsx
178
+ <ScrollArrowGroupLines
179
+ arrows={[
180
+ { start: a, end: b },
181
+ { start: b, end: c },
182
+ ]}
183
+ stagger={1}
184
+ />
185
+ ```
186
+
91
187
  ## Astro
92
188
 
93
189
  The core is DOM-only, so run it in a client script (it must execute in the
@@ -112,14 +208,36 @@ For an Astro React island, use the React API and hydrate with `client:visible`:
112
208
  <Diagram client:visible />
113
209
  ```
114
210
 
211
+ ## SSR & progressive enhancement
212
+
213
+ scroll-arrows is **progressive-enhancement only by design**. The arrow is an
214
+ overlay `<svg>` created by the client script at runtime — there is no
215
+ server-rendered or build-time output. In SSG/SSR setups (Astro, Next, etc.) the
216
+ connector simply does not exist until the script runs, so:
217
+
218
+ - **No-JS / pre-hydration users see nothing** where an arrow would be. Arrows
219
+ are treated as decorative enhancement, not content.
220
+ - Don't encode meaning solely in an arrow. If a relationship must survive without
221
+ JS (accessibility, SEO, no-JS fallback), express it in the DOM too — adjacent
222
+ copy, a list, a caption, an `aria-label` — and let the arrow decorate it.
223
+ - The library ships no static snapshot. Geometry depends on the live, laid-out
224
+ positions of both anchors (and the viewport), which aren't known at build time,
225
+ so a server-rendered arrow would usually be wrong anyway.
226
+
227
+ If you genuinely need a static connector in the un-hydrated state, hand-author a
228
+ plain `<svg>` in your markup and let scroll-arrows draw over it on hydration —
229
+ the runtime arrow mounts in its own overlay and won't clash with your static one.
230
+
115
231
  ## API
116
232
 
117
233
  `scrollArrow(options)` / `new ScrollArrow(options)` → instance with
118
234
  `setProgress(p)`, `refresh()`, `destroy()`.
119
235
 
120
236
  Key options: `start`, `end`, `container`, `roughness`, `stroke`, `strokeWidth`,
121
- `seed`, `startSocket`, `endSocket`, `curvature`, `head`, `headSize`, `scroll`,
122
- `speed`, `easing`, `progress`. Full types ship with the package.
237
+ `seed`, `startSocket`, `endSocket`, `startSocketOffset`, `endSocketOffset`,
238
+ `curvature`, `route`, `head`, `headSize`, `scroll`, `speed`, `easing`,
239
+ `progress`, `enabled`. Full types ship with the package. `setEnabled(on)` toggles
240
+ an arrow (and a group) on/off without teardown.
123
241
 
124
242
  ## Develop
125
243
 
@@ -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
  };
@@ -90,6 +94,26 @@ function buildPath(ep, curvature, belly = { x: 0, y: 0 }) {
90
94
  };
91
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)}`;
92
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
+ }
93
117
  function routeOffset(start, end, obstacles, padding = 14) {
94
118
  const dx = end.x - start.x;
95
119
  const dy = end.y - start.y;
@@ -165,12 +189,29 @@ function easeInOutCubic(t) {
165
189
  function clamp01(t) {
166
190
  return t < 0 ? 0 : t > 1 ? 1 : t;
167
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
+ }
168
198
  function scrollProgress(targetRect, range) {
169
199
  const vh = window.innerHeight || 1;
170
200
  const topFrac = (targetRect.top - window.scrollY) / vh;
171
201
  const [enter, leave] = range;
172
202
  return clamp01((enter - topFrac) / (enter - leave || 1));
173
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
+ }
174
215
  function midpointRect(a, b) {
175
216
  const ra = docRect(a);
176
217
  const rb = docRect(b);
@@ -260,6 +301,25 @@ function clamp012(t) {
260
301
  }
261
302
 
262
303
  // src/draw.ts
304
+ var LABEL_KEYWORDS = {
305
+ start: 0,
306
+ middle: 0.5,
307
+ end: 1
308
+ };
309
+ function resolveLabelAt(value, fallback = 0.5) {
310
+ if (value == null) return fallback;
311
+ if (typeof value === "number") {
312
+ return Number.isFinite(value) ? clamp01(value) : fallback;
313
+ }
314
+ const key = value.trim().toLowerCase();
315
+ const keyword = LABEL_KEYWORDS[key];
316
+ if (keyword !== void 0) return keyword;
317
+ if (key.endsWith("%")) {
318
+ const n = Number.parseFloat(key.slice(0, -1));
319
+ return Number.isFinite(n) ? clamp01(n / 100) : fallback;
320
+ }
321
+ return fallback;
322
+ }
263
323
  function lengths(segs) {
264
324
  let lineLen = 0;
265
325
  let headLen = 0;
@@ -289,7 +349,8 @@ function lineProgress(segs, eased) {
289
349
  return lineLen > 0 ? clamp01(drawn / lineLen) : 1;
290
350
  }
291
351
  function labelOpacity(lineProg, labelAt, fade = 0.08) {
292
- return clamp01((lineProg - clamp01(labelAt)) / (fade || 1));
352
+ const start = Math.min(clamp01(labelAt), 1 - fade);
353
+ return clamp01((lineProg - start) / (fade || 1));
293
354
  }
294
355
 
295
356
  // src/scroll-arrow.ts
@@ -303,8 +364,17 @@ var ScrollArrow = class {
303
364
  this.segments = [];
304
365
  /** Representative line stroke + label nodes, when a label is set. */
305
366
  this.lineEl = null;
367
+ /**
368
+ * The smooth ideal path `d` (pre-roughjs). Label placement measures against
369
+ * this, not `lineEl`: rough.js bakes its double stroke into one path with two
370
+ * subpaths, so `lineEl.getTotalLength()` is ~2x the visible curve and would
371
+ * put `labelAt` at twice its intended fraction.
372
+ */
373
+ this.lineD = "";
306
374
  this.labelEl = null;
307
375
  this.labelBgEl = null;
376
+ /** `opts.labelAt` resolved to a 0..1 fraction; cached by renderLabel for the draw loop. */
377
+ this.resolvedLabelAt = 0.5;
308
378
  this.rafId = 0;
309
379
  this.destroyed = false;
310
380
  this.onScroll = () => {
@@ -328,14 +398,36 @@ var ScrollArrow = class {
328
398
  this.svg = getOverlay(this.container);
329
399
  this.rc = rough.svg(this.svg);
330
400
  this.svg.appendChild(this.group);
331
- this.progress = clamp01(this.opts.progress);
401
+ this.reducedMotion = this.opts.respectReducedMotion !== false && prefersReducedMotion();
402
+ this.progress = this.reducedMotion ? 1 : clamp01(this.opts.progress);
332
403
  this.refs = this.resolveRefs();
333
404
  this.stroke = options.stroke ?? getComputedStyle(this.container).color ?? "#222";
334
405
  this.seed = options.seed ?? deriveSeed(refKey(options.start), refKey(options.end));
406
+ this.enabled = this.opts.enabled ?? true;
407
+ if (!this.enabled) this.group.style.display = "none";
335
408
  this.render();
336
409
  this.bind();
337
410
  this.update();
338
411
  }
412
+ /**
413
+ * Suspend or restore the arrow without tearing it down. Disabling hides it and
414
+ * stops all draw/scroll work; enabling shows it and recomputes geometry (so it
415
+ * reflects any layout change that happened while hidden). Idempotent. Wire it
416
+ * to `matchMedia` to switch arrows off below a breakpoint.
417
+ */
418
+ setEnabled(on) {
419
+ if (on === this.enabled || this.destroyed) return;
420
+ this.enabled = on;
421
+ if (on) {
422
+ this.group.style.display = "";
423
+ this.render();
424
+ this.update();
425
+ } else {
426
+ this.group.style.display = "none";
427
+ cancelAnimationFrame(this.rafId);
428
+ this.rafId = 0;
429
+ }
430
+ }
339
431
  /** Manually set draw progress (0..1). Only meaningful when scroll is false. */
340
432
  setProgress(p) {
341
433
  this.progress = clamp01(p);
@@ -349,6 +441,7 @@ var ScrollArrow = class {
349
441
  destroy() {
350
442
  this.destroyed = true;
351
443
  this.ro?.disconnect();
444
+ this.teardownReveal();
352
445
  window.removeEventListener("scroll", this.onScroll, true);
353
446
  window.removeEventListener("resize", this.onScroll);
354
447
  cancelAnimationFrame(this.rafId);
@@ -374,17 +467,30 @@ var ScrollArrow = class {
374
467
  const list = Array.isArray(a) ? a : [a];
375
468
  return list.map(resolve).filter((el) => el !== null);
376
469
  }
377
- computeEndpoints() {
378
- const sr = docRect(this.refs.start);
379
- const er = docRect(this.refs.end);
470
+ computeEndpoints(sr, er) {
380
471
  const ss = this.opts.startSocket ?? "auto";
381
472
  const es = this.opts.endSocket ?? "auto";
382
- return resolveEndpoints(sr, er, ss, es);
473
+ return resolveEndpoints(
474
+ sr,
475
+ er,
476
+ ss,
477
+ es,
478
+ this.opts.startSocketOffset ?? 0,
479
+ this.opts.endSocketOffset ?? 0
480
+ );
383
481
  }
384
482
  render() {
385
483
  while (this.group.firstChild) this.group.removeChild(this.group.firstChild);
386
484
  this.segments = [];
387
- const ep = this.computeEndpoints();
485
+ if (!this.enabled) return;
486
+ const sr = docRect(this.refs.start);
487
+ const er = docRect(this.refs.end);
488
+ if (isDegenerateRect(sr) || isDegenerateRect(er)) {
489
+ this.armReveal();
490
+ return;
491
+ }
492
+ this.teardownReveal();
493
+ const ep = this.computeEndpoints(sr, er);
388
494
  const origin = overlayOrigin(this.svg);
389
495
  const shift = (e) => ({
390
496
  start: { x: e.start.x - origin.x, y: e.start.y - origin.y },
@@ -401,24 +507,30 @@ var ScrollArrow = class {
401
507
  this.seed,
402
508
  this.opts.anchorEnds ?? true
403
509
  );
404
- const obstacles = this.resolveAvoid().map((el) => {
405
- const dr = docRect(el);
406
- return {
407
- left: dr.left - origin.x,
408
- top: dr.top - origin.y,
409
- width: dr.width,
410
- height: dr.height
411
- };
412
- });
413
- const clear = routeOffset(
414
- local.start,
415
- local.end,
416
- obstacles,
417
- this.opts.avoidPadding ?? 14
418
- );
419
- const BOW = 1.6;
420
- const belly = { x: clear.x * BOW, y: clear.y * BOW };
421
- const d = buildPath(local, curvature, belly);
510
+ let d;
511
+ if (this.opts.route === "elbow") {
512
+ d = buildElbowPath(local);
513
+ } else {
514
+ const obstacles = this.resolveAvoid().map((el) => {
515
+ const dr = docRect(el);
516
+ return {
517
+ left: dr.left - origin.x,
518
+ top: dr.top - origin.y,
519
+ width: dr.width,
520
+ height: dr.height
521
+ };
522
+ });
523
+ const clear = routeOffset(
524
+ local.start,
525
+ local.end,
526
+ obstacles,
527
+ this.opts.avoidPadding ?? 14
528
+ );
529
+ const BOW = 1.6;
530
+ const belly = { x: clear.x * BOW, y: clear.y * BOW };
531
+ d = buildPath(local, curvature, belly);
532
+ }
533
+ this.lineD = d;
422
534
  this.appendDrawable(this.rc.path(d, roughOpts), "line");
423
535
  const head = this.opts.head;
424
536
  const size = this.opts.headSize;
@@ -455,25 +567,26 @@ var ScrollArrow = class {
455
567
  this.labelEl = null;
456
568
  this.labelBgEl = null;
457
569
  const text = this.opts.label;
458
- if (!text || !this.lineEl) return;
459
- const total = this.lineEl.getTotalLength();
460
- const at = clampAt(this.opts.labelAt ?? 0.5);
461
- const pt = this.lineEl.getPointAtLength(at * total);
570
+ if (!text || !this.lineEl || !this.lineD) return;
571
+ const at = resolveLabelAt(this.opts.labelAt);
572
+ this.resolvedLabelAt = at;
573
+ const measure = createSvgEl("path");
574
+ measure.setAttribute("d", this.lineD);
575
+ this.group.appendChild(measure);
576
+ const total = measure.getTotalLength();
577
+ const pt = measure.getPointAtLength(at * total);
462
578
  const offset = this.opts.labelOffset ?? 0;
463
579
  let x = pt.x;
464
580
  let y = pt.y;
465
581
  if (offset && total > 0) {
466
582
  const eps = Math.min(1, total / 2);
467
- const before = this.lineEl.getPointAtLength(
468
- Math.max(0, at * total - eps)
469
- );
470
- const after = this.lineEl.getPointAtLength(
471
- Math.min(total, at * total + eps)
472
- );
583
+ const before = measure.getPointAtLength(Math.max(0, at * total - eps));
584
+ const after = measure.getPointAtLength(Math.min(total, at * total + eps));
473
585
  const n = unitNormal(before, after);
474
586
  x += n.x * offset;
475
587
  y += n.y * offset;
476
588
  }
589
+ this.group.removeChild(measure);
477
590
  const label = createSvgEl("text");
478
591
  label.textContent = text;
479
592
  label.setAttribute("x", String(x));
@@ -523,25 +636,43 @@ var ScrollArrow = class {
523
636
  if (this.labelEl) {
524
637
  const op = labelOpacity(
525
638
  lineProgress(this.segments, eased),
526
- this.opts.labelAt ?? 0.5
639
+ this.resolvedLabelAt
527
640
  );
528
641
  this.labelEl.style.opacity = String(op);
529
642
  if (this.labelBgEl) this.labelBgEl.style.opacity = String(op);
530
643
  }
531
644
  }
645
+ /**
646
+ * Watch the anchors for the moment a hidden one becomes laid out, then redraw.
647
+ * IntersectionObserver fires when a previously `display:none` element gains a
648
+ * box — a transition ResizeObserver does not reliably report. Armed lazily and
649
+ * idempotently; torn down by the next successful render(). Guarded so it no-ops
650
+ * in environments without IntersectionObserver (older engines, some SSR/jsdom).
651
+ */
652
+ armReveal() {
653
+ if (this.revealObs || this.destroyed) return;
654
+ if (typeof IntersectionObserver === "undefined") return;
655
+ this.revealObs = new IntersectionObserver(() => this.render());
656
+ this.revealObs.observe(this.refs.start);
657
+ this.revealObs.observe(this.refs.end);
658
+ }
659
+ teardownReveal() {
660
+ this.revealObs?.disconnect();
661
+ this.revealObs = void 0;
662
+ }
532
663
  bind() {
533
664
  const targets = [this.refs.start, this.refs.end, ...this.resolveAvoid()];
534
665
  if (this.refs.target) targets.push(this.refs.target);
535
666
  this.ro = new ResizeObserver(() => this.render());
536
667
  targets.forEach((t) => this.ro.observe(t));
537
- if (this.opts.scroll !== false) {
668
+ if (this.opts.scroll !== false && !this.reducedMotion) {
538
669
  window.addEventListener("scroll", this.onScroll, true);
539
670
  window.addEventListener("resize", this.onScroll);
540
671
  }
541
672
  }
542
673
  update() {
543
- if (this.destroyed) return;
544
- if (this.opts.scroll === false) {
674
+ if (this.destroyed || !this.enabled) return;
675
+ if (this.opts.scroll === false || this.reducedMotion) {
545
676
  this.applyProgress();
546
677
  return;
547
678
  }
@@ -559,10 +690,141 @@ function resolve(ref) {
559
690
  function refKey(ref) {
560
691
  return typeof ref === "string" ? ref : ref.tagName + (ref.id ? "#" + ref.id : "");
561
692
  }
562
- function clampAt(t) {
563
- return t < 0 ? 0 : t > 1 ? 1 : t;
693
+
694
+ // src/group.ts
695
+ var ScrollArrowGroup = class {
696
+ constructor(options) {
697
+ this.elements = [];
698
+ this.target = null;
699
+ this.progress = 0;
700
+ this.rafId = 0;
701
+ this.destroyed = false;
702
+ this.onScroll = () => {
703
+ if (this.rafId) return;
704
+ this.rafId = requestAnimationFrame(() => {
705
+ this.rafId = 0;
706
+ this.update();
707
+ });
708
+ };
709
+ if (!options.arrows || options.arrows.length === 0) {
710
+ throw new Error("[scroll-arrows] group needs at least one arrow");
711
+ }
712
+ this.opts = {
713
+ stagger: 1,
714
+ speed: 1,
715
+ easing: (t) => t,
716
+ ...options
717
+ };
718
+ this.reducedMotion = this.opts.respectReducedMotion !== false && prefersReducedMotion();
719
+ this.enabled = this.opts.enabled ?? true;
720
+ this.arrows = options.arrows.map(
721
+ (a) => new ScrollArrow({
722
+ ...a,
723
+ scroll: false,
724
+ progress: 0,
725
+ respectReducedMotion: false,
726
+ enabled: this.enabled
727
+ })
728
+ );
729
+ this.windows = staggerWindows(this.arrows.length, this.opts.stagger);
730
+ for (const a of options.arrows) {
731
+ const s = resolve2(a.start);
732
+ const e = resolve2(a.end);
733
+ if (s) this.elements.push(s);
734
+ if (e) this.elements.push(e);
735
+ }
736
+ const scroll = this.opts.scroll;
737
+ if (scroll !== false && scroll?.target) {
738
+ this.target = resolve2(scroll.target);
739
+ }
740
+ if (this.reducedMotion) this.progress = 1;
741
+ this.bind();
742
+ this.update();
743
+ }
744
+ /** Manually set group progress (0..1). Only meaningful when scroll is false. */
745
+ setProgress(p) {
746
+ this.progress = clamp01(p);
747
+ this.distribute();
748
+ }
749
+ /** Recompute geometry for every arrow (call after layout changes). */
750
+ refresh() {
751
+ this.arrows.forEach((a) => a.refresh());
752
+ this.update();
753
+ }
754
+ /**
755
+ * Suspend or restore the whole group without tearing it down. Hides every
756
+ * arrow and stops scroll work when off; restores and recomputes when on.
757
+ * Idempotent. Wire to `matchMedia` for breakpoint-aware diagrams.
758
+ */
759
+ setEnabled(on) {
760
+ if (on === this.enabled || this.destroyed) return;
761
+ this.enabled = on;
762
+ this.arrows.forEach((a) => a.setEnabled(on));
763
+ if (on) {
764
+ this.bind();
765
+ this.update();
766
+ } else {
767
+ window.removeEventListener("scroll", this.onScroll, true);
768
+ window.removeEventListener("resize", this.onScroll);
769
+ cancelAnimationFrame(this.rafId);
770
+ this.rafId = 0;
771
+ }
772
+ }
773
+ destroy() {
774
+ this.destroyed = true;
775
+ window.removeEventListener("scroll", this.onScroll, true);
776
+ window.removeEventListener("resize", this.onScroll);
777
+ cancelAnimationFrame(this.rafId);
778
+ this.arrows.forEach((a) => a.destroy());
779
+ }
780
+ // --- internals ---------------------------------------------------------
781
+ bind() {
782
+ if (this.opts.scroll === false || this.reducedMotion || !this.enabled)
783
+ return;
784
+ window.addEventListener("scroll", this.onScroll, true);
785
+ window.addEventListener("resize", this.onScroll);
786
+ }
787
+ update() {
788
+ if (this.destroyed || !this.enabled) return;
789
+ if (this.opts.scroll === false || this.reducedMotion) {
790
+ this.distribute();
791
+ return;
792
+ }
793
+ const scroll = this.opts.scroll ?? {};
794
+ const range = scroll.range ?? [0.85, 0.35];
795
+ const rect = this.target ? docRect(this.target) : this.groupRect();
796
+ const raw = scrollProgress(rect, range);
797
+ this.progress = clamp01(raw * this.opts.speed);
798
+ this.distribute();
799
+ }
800
+ /** Push each arrow to its sliced local progress for the current group value. */
801
+ distribute() {
802
+ const eased = clamp01(this.opts.easing(this.progress));
803
+ this.arrows.forEach((arrow, i) => {
804
+ arrow.setProgress(windowProgress(eased, this.windows[i]));
805
+ });
806
+ }
807
+ /** Synthetic rect spanning every endpoint, used as the default trigger. */
808
+ groupRect() {
809
+ const rects = this.elements.map(docRect);
810
+ if (rects.length === 0) return { left: 0, top: 0, width: 0, height: 0 };
811
+ let left = Infinity;
812
+ let top = Infinity;
813
+ let right = -Infinity;
814
+ let bottom = -Infinity;
815
+ for (const r2 of rects) {
816
+ left = Math.min(left, r2.left);
817
+ top = Math.min(top, r2.top);
818
+ right = Math.max(right, r2.left + r2.width);
819
+ bottom = Math.max(bottom, r2.top + r2.height);
820
+ }
821
+ return { left, top, width: right - left, height: bottom - top };
822
+ }
823
+ };
824
+ function resolve2(ref) {
825
+ return typeof ref === "string" ? document.querySelector(ref) : ref;
564
826
  }
565
827
 
566
- export { ScrollArrow, easeInOutCubic };
567
- //# sourceMappingURL=chunk-GKWBGFLA.js.map
568
- //# sourceMappingURL=chunk-GKWBGFLA.js.map
828
+ export { ScrollArrow, ScrollArrowGroup, easeInOutCubic };
829
+ //# sourceMappingURL=chunk-GX722UDR.js.map
830
+ //# sourceMappingURL=chunk-GX722UDR.js.map