Skip to content

Commit 5c64f2c

Browse files
committed
refactor(website): extract shared math, media and rAF helpers
1 parent 7c6cb45 commit 5c64f2c

10 files changed

Lines changed: 77 additions & 30 deletions

File tree

website/src/components/common/SpotlightGroup/SpotlightGroup.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import { useEffect, useRef, type HTMLAttributes } from 'react';
44

5+
import { hasFinePointer } from '@/lib/media';
6+
import { rafThrottle } from '@/lib/rafThrottle';
7+
58
/**
69
* Publishes the pointer's position over the hovered `[data-spotlight]` descendant
710
* as `--mx` / `--my`, in px relative to that element's own box.
@@ -25,9 +28,8 @@ export default function SpotlightGroup({
2528
if (!root) return;
2629

2730
// A spotlight is a hover affordance; a coarse pointer has nothing to track.
28-
if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
31+
if (!hasFinePointer()) return;
2932

30-
let frame = 0;
3133
let target: HTMLElement | null = null;
3234
let clientX = 0;
3335
let clientY = 0;
@@ -37,13 +39,12 @@ export default function SpotlightGroup({
3739
el?.style.removeProperty('--my');
3840
};
3941

40-
const apply = () => {
41-
frame = 0;
42+
const apply = rafThrottle(() => {
4243
if (!target) return;
4344
const rect = target.getBoundingClientRect();
4445
target.style.setProperty('--mx', `${clientX - rect.left}px`);
4546
target.style.setProperty('--my', `${clientY - rect.top}px`);
46-
};
47+
});
4748

4849
const onMove = (event: PointerEvent) => {
4950
const next = (event.target as Element | null)?.closest<HTMLElement>('[data-spotlight]') ?? null;
@@ -56,7 +57,7 @@ export default function SpotlightGroup({
5657

5758
clientX = event.clientX;
5859
clientY = event.clientY;
59-
if (!frame) frame = requestAnimationFrame(apply);
60+
apply();
6061
};
6162

6263
const onLeave = () => {
@@ -70,7 +71,7 @@ export default function SpotlightGroup({
7071
return () => {
7172
root.removeEventListener('pointermove', onMove);
7273
root.removeEventListener('pointerleave', onLeave);
73-
if (frame) cancelAnimationFrame(frame);
74+
apply.cancel();
7475
};
7576
}, []);
7677

website/src/components/landing/Roadmap/index.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Image from 'next/image';
55

66
import { ROADMAP, ROADMAP_STATUS_LABEL, SECTION_COPY } from '@/content/landing';
77
import useScrollScene from '@/hooks/useScrollScene';
8+
import { clamp01 } from '@/lib/math';
89
import './Roadmap.css';
910

1011
const { title: sectionTitle, subtitle } = SECTION_COPY.roadmap;
@@ -23,8 +24,6 @@ const PASS_FADE = 0.28;
2324
Kept shallow so the overshoot never balloons over the header strip. */
2425
const PASS_OVERSHOOT = 0.18;
2526

26-
const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);
27-
2827
const Roadmap = () => {
2928
const gates = useRef<(HTMLElement | null)[]>([]);
3029
const stages = useRef<(HTMLElement | null)[]>([]);

website/src/components/landing/Stats/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useEffect, useRef } from 'react';
44

55
import { STATS, type Stat } from '@/content/landing';
6+
import { prefersReducedMotion } from '@/lib/media';
67
import './Stats.css';
78

89
const COUNT_MS = 1500;
@@ -28,7 +29,7 @@ const Stats = () => {
2829
});
2930
};
3031

31-
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
32+
if (prefersReducedMotion()) return;
3233

3334
let frame = 0;
3435
let startedAt = 0;

website/src/components/layout/SiteHeader/SiteHeader.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { NAV_LINKS, SITE } from '@/content/landing';
88
import useActiveSection from '@/hooks/useActiveSection';
99
import useHashLanding from '@/hooks/useHashLanding';
1010
import useHeaderScroll from '@/hooks/useHeaderScroll';
11+
import { prefersReducedMotion } from '@/lib/media';
1112
import { openApp } from '@/lib/openApp';
1213
import { findSection, scrollToSection } from '@/lib/scrollToSection';
1314
import './SiteHeader.css';
@@ -51,8 +52,7 @@ export default function SiteHeader({ title }: SiteHeaderProps) {
5152
close();
5253
if (fromDrawer) toggleRef.current?.focus();
5354

54-
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
55-
scrollToSection(target, { smooth: !reduced });
55+
scrollToSection(target, { smooth: !prefersReducedMotion() });
5656
window.history.pushState(null, '', href);
5757
},
5858
[close],

website/src/hooks/useHeaderScroll.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import { useEffect, useRef, useState } from 'react';
44

5+
import { rafThrottle } from '@/lib/rafThrottle';
6+
57
/** Distance scrolled before the header condenses, in px. */
68
const CONDENSE_AT = 24;
79

@@ -21,7 +23,6 @@ export default function useHeaderScroll<T extends HTMLElement>() {
2123

2224
useEffect(() => {
2325
const header = ref.current;
24-
let frame = 0;
2526

2627
/**
2728
* Publishes the header's real height so anchor offsets cannot drift from
@@ -48,7 +49,6 @@ export default function useHeaderScroll<T extends HTMLElement>() {
4849
};
4950

5051
const update = () => {
51-
frame = 0;
5252
const y = window.scrollY;
5353

5454
header?.style.setProperty(
@@ -58,10 +58,7 @@ export default function useHeaderScroll<T extends HTMLElement>() {
5858
setCondensed(y > CONDENSE_AT);
5959
};
6060

61-
const onScroll = () => {
62-
if (frame) return;
63-
frame = requestAnimationFrame(update);
64-
};
61+
const onScroll = rafThrottle(update);
6562

6663
/* Watching the body catches the document growing as images decode and
6764
sections reveal, which is what actually changes the scrollable extent. */
@@ -81,7 +78,7 @@ export default function useHeaderScroll<T extends HTMLElement>() {
8178
return () => {
8279
window.removeEventListener('scroll', onScroll);
8380
sizeObserver.disconnect();
84-
if (frame) cancelAnimationFrame(frame);
81+
onScroll.cancel();
8582
};
8683
}, []);
8784

website/src/hooks/useRevealObserver.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import { useEffect } from 'react';
44

5+
import { prefersReducedMotion } from '@/lib/media';
6+
57
const VISIBLE_ATTR = 'data-reveal-visible';
68
const DEFAULT_STAGGER_MS = 70;
79

@@ -23,7 +25,7 @@ export default function useRevealObserver() {
2325

2426
// The CSS also honours the preference, but bailing here avoids installing an
2527
// observer that would only ever toggle attributes nothing reads.
26-
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
28+
if (prefersReducedMotion()) {
2729
targets.forEach((el) => el.setAttribute(VISIBLE_ATTR, ''));
2830
return;
2931
}

website/src/hooks/useScrollScene.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import { useEffect, useRef } from 'react';
44

5-
const clamp = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);
5+
import { clamp01 } from '@/lib/math';
6+
import { REDUCED_MOTION } from '@/lib/media';
7+
import { rafThrottle } from '@/lib/rafThrottle';
68

79
/**
810
* Drives a 0-1 progress value from how far a tall section has been scrolled
@@ -45,9 +47,8 @@ export default function useScrollScene<
4547
if (!section || !pin) return;
4648

4749
const narrow = window.matchMedia(fallbackQuery);
48-
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
50+
const reduced = window.matchMedia(REDUCED_MOTION);
4951

50-
let frame = 0;
5152
let attached = false;
5253

5354
/* Cached between frames. These change when the viewport does, not when the
@@ -73,18 +74,15 @@ export default function useScrollScene<
7374
};
7475

7576
const update = () => {
76-
frame = 0;
7777
if (scrollable <= 0) {
7878
write(1);
7979
return;
8080
}
8181
const travelled = stickyTop - section.getBoundingClientRect().top;
82-
write(clamp(travelled / scrollable));
82+
write(clamp01(travelled / scrollable));
8383
};
8484

85-
const onScroll = () => {
86-
if (!frame) frame = requestAnimationFrame(update);
87-
};
85+
const onScroll = rafThrottle(update);
8886

8987
/* Both elements are sized in viewport units, so this fires on viewport
9088
resize as well as any layout change that moves them. */
@@ -107,8 +105,7 @@ export default function useScrollScene<
107105
if (!attached) return;
108106
attached = false;
109107
window.removeEventListener('scroll', onScroll);
110-
if (frame) cancelAnimationFrame(frame);
111-
frame = 0;
108+
onScroll.cancel();
112109
};
113110

114111
const sync = () => {

website/src/lib/math.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** Clamps to the 0-1 range every progress value on this site is expressed in. */
2+
export const clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n);

website/src/lib/media.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Media queries the site branches on.
3+
*
4+
* Named once because a mistyped query is a silent failure: it simply never
5+
* matches, so the guard it was meant to be quietly stops guarding and the
6+
* behaviour it protected runs for everyone.
7+
*/
8+
9+
export const REDUCED_MOTION = '(prefers-reduced-motion: reduce)';
10+
11+
/** A pointer that can hover and aim precisely — mouse or trackpad, not touch. */
12+
export const FINE_POINTER = '(hover: hover) and (pointer: fine)';
13+
14+
export const prefersReducedMotion = (): boolean => window.matchMedia(REDUCED_MOTION).matches;
15+
16+
export const hasFinePointer = (): boolean => window.matchMedia(FINE_POINTER).matches;

website/src/lib/rafThrottle.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export interface RafThrottled {
2+
(): void;
3+
/** Drops any frame already booked. Call from effect cleanup. */
4+
cancel(): void;
5+
}
6+
7+
/**
8+
* Collapses a burst of calls into one call per animation frame.
9+
*
10+
* The pattern every scroll and pointer listener here needs: events fire far
11+
* faster than the screen refreshes, so running the handler per event is work
12+
* thrown away before anything is painted.
13+
*/
14+
export const rafThrottle = (fn: () => void): RafThrottled => {
15+
let frame = 0;
16+
17+
const run = () => {
18+
frame = 0;
19+
fn();
20+
};
21+
22+
const throttled = (() => {
23+
if (!frame) frame = requestAnimationFrame(run);
24+
}) as RafThrottled;
25+
26+
throttled.cancel = () => {
27+
if (frame) cancelAnimationFrame(frame);
28+
frame = 0;
29+
};
30+
31+
return throttled;
32+
};

0 commit comments

Comments
 (0)