1/** Runs homepage motion (marquee, mark drift, caret, parallax) only while its content is visible. */
2export function startHomeMotion({
3 hero,
4 field,
5 tracks,
6 caret,
7}: {
8 hero: HTMLElement;
9 field: HTMLElement;
10 tracks: HTMLElement[];
11 caret: HTMLElement;
12}) {
13 if (typeof IntersectionObserver === "undefined") return () => {};
14
15 const marks = Array.from(field.querySelectorAll<HTMLElement>(".hero-float-mark"));
16 const gated = [...marks, ...tracks, caret];
17 const visible = new Set<Element>();
18 const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
19 const finePointer = window.matchMedia("(pointer: fine)");
20 const events = new AbortController();
21 const eventOptions = { signal: events.signal };
22 let disposed = false;
23 let pointerFrame: number | undefined;
24 let pointer: { x: number; y: number } | null = null;
25
26 const canMove = (element: Element) =>
27 !disposed &&
28 visible.has(element) &&
29 document.visibilityState === "visible" &&
30 !reducedMotion.matches;
31 const canParallax = () => finePointer.matches && marks.some(canMove);
32
33 function resetPointer() {
34 if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame);
35 pointerFrame = undefined;
36 pointer = null;
37 field.style.setProperty("--px", "0px");
38 field.style.setProperty("--py", "0px");
39 }
40
41 function update() {
42 for (const element of gated) {
43 element.style.setProperty("--home-motion-state", canMove(element) ? "running" : "paused");
44 }
45 const parallax = canParallax();
46 field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s");
47 if (!parallax) resetPointer();
48 }
49
50 const observer = new IntersectionObserver((entries) => {
51 if (disposed) return;
52 for (const entry of entries) {
53 if (entry.isIntersecting) visible.add(entry.target);
54 else visible.delete(entry.target);
55 }
56 update();
57 });
58 for (const element of gated) observer.observe(element);
59
60 hero.addEventListener(
61 "pointermove",
62 (event) => {
63 if (!canParallax()) return;
64 pointer = { x: event.clientX, y: event.clientY };
65 pointerFrame ??= requestAnimationFrame(() => {
66 pointerFrame = undefined;
67 if (!pointer || !canParallax()) return;
68 const bounds = hero.getBoundingClientRect();
69 if (bounds.width === 0 || bounds.height === 0) return;
70 field.style.setProperty(
71 "--px",
72 `${(((pointer.x - bounds.left) / bounds.width - 0.5) * 36).toFixed(1)}px`,
73 );
74 field.style.setProperty(
75 "--py",
76 `${(((pointer.y - bounds.top) / bounds.height - 0.5) * 28).toFixed(1)}px`,
77 );
78 });
79 },
80 eventOptions,
81 );
82 hero.addEventListener("pointerleave", resetPointer, eventOptions);
83 document.addEventListener("visibilitychange", update, eventOptions);
84 reducedMotion.addEventListener("change", update, eventOptions);
85 finePointer.addEventListener("change", update, eventOptions);
86 update();
87
88 return () => {
89 if (disposed) return;
90 disposed = true;
91 events.abort();
92 observer.disconnect();
93 update();
94 };
95}