Skip to content

Commit 44c7206

Browse files
authored
perf(website): Optimize animation in website for some outdated devices (#177)
* fix: pause tape animations offscreen * fix: stop hook animation when inactive * fix: pause testimonial marquee when inactive * fix: precompute hook animation samples * fix: throttle hook animation updates * fix: remove expensive hook svg blur filters * fix: avoid animating tape live shadow
1 parent 3a7304d commit 44c7206

3 files changed

Lines changed: 197 additions & 99 deletions

File tree

website/src/components/HookIntro.astro

Lines changed: 116 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -75,23 +75,6 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
7575
fill="none"
7676
aria-hidden="true"
7777
>
78-
<defs>
79-
<filter id="hook-glow" x="-100%" y="-100%" width="300%" height="300%">
80-
<feGaussianBlur stdDeviation="5" result="blur" />
81-
<feMerge>
82-
<feMergeNode in="blur" />
83-
<feMergeNode in="SourceGraphic" />
84-
</feMerge>
85-
</filter>
86-
<filter id="hook-glow-sm" x="-100%" y="-100%" width="300%" height="300%">
87-
<feGaussianBlur stdDeviation="2.5" result="blur" />
88-
<feMerge>
89-
<feMergeNode in="blur" />
90-
<feMergeNode in="SourceGraphic" />
91-
</feMerge>
92-
</filter>
93-
</defs>
94-
9578
<!-- Desktop: serpentine S-curve paths -->
9679
<g class="hook-paths-desktop">
9780
<path
@@ -132,24 +115,9 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
132115
/>
133116
</g>
134117

135-
<!-- Traveling dot (main) -->
136-
<circle
137-
r="5.5"
138-
cx="-50" cy="-50"
139-
fill="var(--primary)"
140-
filter="url(#hook-glow)"
141-
visibility="hidden"
142-
data-hook-dot
143-
/>
144-
<!-- Trailing dot -->
145-
<circle
146-
r="3"
147-
cx="-50" cy="-50"
148-
fill="var(--primary)"
149-
filter="url(#hook-glow-sm)"
150-
visibility="hidden"
151-
data-hook-dot-trail
152-
/>
118+
<!-- Traveling dots avoid animated SVG blur filters; the trail supplies the glow cue. -->
119+
<circle r="5.5" cx="-50" cy="-50" fill="var(--primary)" opacity="0" visibility="hidden" data-hook-dot />
120+
<circle r="3" cx="-50" cy="-50" fill="var(--primary)" opacity="0" visibility="hidden" data-hook-dot-trail />
153121
</svg>
154122

155123
<!-- Node cards -->
@@ -295,7 +263,7 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
295263
</style>
296264

297265
<script>
298-
import { animate, inView } from 'motion';
266+
import { inView } from 'motion';
299267

300268
type HookFlowWindow = Window & {
301269
__bubHookFlowCleanup?: () => void;
@@ -313,6 +281,16 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
313281
const TRAIL_LEAD = 0.018;
314282
const FADE_ZONE = 0.06;
315283
const NEAR_DIST_SQ = 36 * 36;
284+
const PATH_SAMPLE_COUNT = 560;
285+
const HOOK_FRAME_MS = 1000 / 30;
286+
const REPEAT_DELAY_MS = 400;
287+
288+
type PathSample = {
289+
x: number;
290+
y: number;
291+
opacity: number;
292+
nearest: number;
293+
};
316294

317295
/** Fade envelope: ramps in/out at path ends, 1.0 in the middle. */
318296
function fade(v: number): number {
@@ -330,6 +308,7 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
330308
if (!root) return;
331309

332310
let stopAnim: (() => void) | null = null;
311+
let rootInView = false;
333312
let activeIdx = -1;
334313
let currentMobile: boolean | null = null;
335314

@@ -339,6 +318,18 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
339318
if (!dot || !nodeEls.length) return;
340319

341320
const mql = window.matchMedia('(max-width: 639px)');
321+
const reduceMotionMql = window.matchMedia('(prefers-reduced-motion: reduce)');
322+
323+
function stopAnimation() {
324+
stopAnim?.();
325+
stopAnim = null;
326+
currentMobile = null;
327+
}
328+
329+
function maybeStartAnimation() {
330+
if (!rootInView || document.visibilityState === 'hidden' || reduceMotionMql.matches) return;
331+
setupAnimation(mql.matches);
332+
}
342333

343334
function setupAnimation(isMobile: boolean) {
344335
if (isMobile === currentMobile) return;
@@ -365,51 +356,75 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
365356
};
366357
});
367358

359+
const samples: PathSample[] = Array.from({ length: PATH_SAMPLE_COUNT + 1 }, (_, sampleIndex) => {
360+
const v = sampleIndex / PATH_SAMPLE_COUNT;
361+
const pt = svgPath.getPointAtLength(v * totalLen);
362+
let nearest = -1;
363+
let bestSq = NEAR_DIST_SQ;
364+
365+
for (let i = 0; i < nodeCoords.length; i++) {
366+
const dx = pt.x - nodeCoords[i].x;
367+
const dy = pt.y - nodeCoords[i].y;
368+
const dSq = dx * dx + dy * dy;
369+
if (dSq < bestSq) { bestSq = dSq; nearest = i; }
370+
}
371+
372+
return {
373+
x: pt.x,
374+
y: pt.y,
375+
opacity: fade(v),
376+
nearest,
377+
};
378+
});
379+
368380
function highlightNode(idx: number) {
369381
if (idx === activeIdx) return;
370382
activeIdx = idx;
371383
nodeEls.forEach((el, i) => el.classList.toggle('active', i === idx));
372384
}
373385

374-
const controls = animate(0, 1, {
375-
duration: CYCLE_DUR,
376-
repeat: Infinity,
377-
repeatDelay: 0.4,
378-
ease: 'linear',
379-
onUpdate(v: number) {
380-
const f = fade(v);
381-
382-
const pt = svgPath!.getPointAtLength(v * totalLen);
383-
dot!.setAttribute('cx', String(pt.x));
384-
dot!.setAttribute('cy', String(pt.y));
385-
dot!.setAttribute('opacity', String(f));
386-
dot!.setAttribute('visibility', 'visible');
387-
388-
if (dotTrail) {
389-
const pt2 = svgPath!.getPointAtLength(Math.max(0, v - TRAIL_LEAD) * totalLen);
390-
dotTrail.setAttribute('cx', String(pt2.x));
391-
dotTrail.setAttribute('cy', String(pt2.y));
392-
dotTrail.setAttribute('opacity', String(f * 0.35));
393-
dotTrail.setAttribute('visibility', 'visible');
394-
}
395-
396-
trail!.style.strokeDashoffset = String(0.14 - v);
397-
trail!.style.opacity = String(f * 0.35);
398-
399-
let nearest = -1;
400-
let bestSq = NEAR_DIST_SQ;
401-
for (let i = 0; i < nodeCoords.length; i++) {
402-
const dx = pt.x - nodeCoords[i].x;
403-
const dy = pt.y - nodeCoords[i].y;
404-
const dSq = dx * dx + dy * dy;
405-
if (dSq < bestSq) { bestSq = dSq; nearest = i; }
406-
}
407-
highlightNode(nearest);
408-
},
409-
});
386+
const cycleMs = CYCLE_DUR * 1000;
387+
const loopMs = cycleMs + REPEAT_DELAY_MS;
388+
const startedAt = performance.now();
389+
390+
function updateFrame() {
391+
const elapsed = (performance.now() - startedAt) % loopMs;
392+
if (elapsed > cycleMs) {
393+
dot!.setAttribute('opacity', '0');
394+
dotTrail?.setAttribute('opacity', '0');
395+
trail!.style.opacity = '0';
396+
highlightNode(-1);
397+
return;
398+
}
399+
400+
const v = elapsed / cycleMs;
401+
const sampleIndex = Math.min(PATH_SAMPLE_COUNT, Math.max(0, Math.round(v * PATH_SAMPLE_COUNT)));
402+
const sample = samples[sampleIndex];
403+
404+
dot!.setAttribute('cx', String(sample.x));
405+
dot!.setAttribute('cy', String(sample.y));
406+
dot!.setAttribute('opacity', String(sample.opacity));
407+
dot!.setAttribute('visibility', 'visible');
408+
409+
if (dotTrail) {
410+
const trailIndex = Math.max(0, sampleIndex - Math.round(TRAIL_LEAD * PATH_SAMPLE_COUNT));
411+
const trailSample = samples[trailIndex];
412+
dotTrail.setAttribute('cx', String(trailSample.x));
413+
dotTrail.setAttribute('cy', String(trailSample.y));
414+
dotTrail.setAttribute('opacity', String(sample.opacity * 0.35));
415+
dotTrail.setAttribute('visibility', 'visible');
416+
}
417+
418+
trail!.style.strokeDashoffset = String(0.14 - v);
419+
trail!.style.opacity = String(sample.opacity * 0.35);
420+
highlightNode(sample.nearest);
421+
}
422+
423+
updateFrame();
424+
const intervalId = window.setInterval(updateFrame, HOOK_FRAME_MS);
410425

411426
stopAnim = () => {
412-
controls.stop();
427+
window.clearInterval(intervalId);
413428
dot!.setAttribute('opacity', '0');
414429
dot!.setAttribute('visibility', 'hidden');
415430
dotTrail?.setAttribute('opacity', '0');
@@ -423,29 +438,46 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
423438

424439
const handleMediaChange = () => {
425440
const wasRunning = stopAnim !== null;
426-
stopAnim?.();
427-
stopAnim = null;
428-
currentMobile = null;
429-
if (wasRunning) setupAnimation(mql.matches);
441+
stopAnimation();
442+
if (wasRunning) maybeStartAnimation();
443+
};
444+
445+
const handleMotionPreferenceChange = () => {
446+
if (reduceMotionMql.matches) {
447+
stopAnimation();
448+
} else {
449+
maybeStartAnimation();
450+
}
451+
};
452+
453+
const handleVisibilityChange = () => {
454+
if (document.visibilityState === 'hidden') {
455+
stopAnimation();
456+
} else {
457+
maybeStartAnimation();
458+
}
430459
};
431460

432461
mql.addEventListener('change', handleMediaChange);
462+
reduceMotionMql.addEventListener('change', handleMotionPreferenceChange);
463+
document.addEventListener('visibilitychange', handleVisibilityChange);
433464

434465
const stopInView = inView(root, () => {
435-
setupAnimation(mql.matches);
466+
rootInView = true;
467+
maybeStartAnimation();
436468
return () => {
437-
stopAnim?.();
438-
stopAnim = null;
439-
currentMobile = null;
469+
rootInView = false;
470+
stopAnimation();
440471
};
441472
}, { margin: '-8% 0px' });
442473

443474
hookFlowWindow.__bubHookFlowCleanup = () => {
444475
stopInView();
445476
mql.removeEventListener('change', handleMediaChange);
446-
stopAnim?.();
447-
stopAnim = null;
448-
currentMobile = null;
477+
reduceMotionMql.removeEventListener('change', handleMotionPreferenceChange);
478+
document.removeEventListener('visibilitychange', handleVisibilityChange);
479+
rootInView = false;
480+
stopAnimation();
449481
};
450482
}
451483

0 commit comments

Comments
 (0)