Skip to content

Commit 7c6cb45

Browse files
committed
refactor(website): extract section navigation out of the header
1 parent d341920 commit 7c6cb45

3 files changed

Lines changed: 171 additions & 131 deletions

File tree

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

Lines changed: 8 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import clsx from 'clsx';
66
import { NeonButton } from '@/components/common';
77
import { NAV_LINKS, SITE } from '@/content/landing';
88
import useActiveSection from '@/hooks/useActiveSection';
9+
import useHashLanding from '@/hooks/useHashLanding';
910
import useHeaderScroll from '@/hooks/useHeaderScroll';
1011
import { openApp } from '@/lib/openApp';
12+
import { findSection, scrollToSection } from '@/lib/scrollToSection';
1113
import './SiteHeader.css';
1214

1315
/** Module scope keeps the array referentially stable across renders. */
@@ -16,113 +18,6 @@ const NAV_IDS = NAV_LINKS.map(({ href }) => href.replace('#', ''));
1618
/** Width at which the inline nav gives way to the drawer. Matches SiteHeader.css. */
1719
const DRAWER_QUERY = '(max-width: 900px)';
1820

19-
/**
20-
* How far `target` is from where it should land, in px.
21-
*
22-
* Two cases, because the header is a floating pill and not an opaque bar —
23-
* nothing hides the strip of viewport around it, so a landing must never leave
24-
* the previous section visible in that strip:
25-
*
26-
* - A section with a full-viewport pinned scene lands flush at the viewport
27-
* top. The scene owns the whole screen and pads its own content clear of the
28-
* glass.
29-
* - Any other section lands with its content resting at the anchor offset when
30-
* its own top padding is deep enough to reach the border, and at the
31-
* header's edge otherwise — never higher, so the heading cannot tuck under
32-
* the glass, and never lower than its padding can cover, so the previous
33-
* section's tail cannot show above the border.
34-
*
35-
* The offset is read back from the resolved `scroll-padding-top`: custom
36-
* properties do not resolve calc() through getComputedStyle, real properties
37-
* do, so this is the one place the number exists and JS and CSS cannot drift.
38-
*/
39-
const landingError = (target: Element) => {
40-
const pin = target.querySelector('.pin');
41-
if (pin && getComputedStyle(pin).position === 'sticky') {
42-
return Math.round(target.getBoundingClientRect().top);
43-
}
44-
45-
const root = getComputedStyle(document.documentElement);
46-
const offset = parseFloat(root.scrollPaddingTop) || 0;
47-
const gap = parseFloat(root.getPropertyValue('--anchor-gap')) || 0;
48-
const headerHeight = offset - gap;
49-
50-
const padding = parseFloat(getComputedStyle(target).paddingTop) || 0;
51-
const contentY = Math.max(headerHeight, Math.min(padding, offset));
52-
53-
return Math.round(target.getBoundingClientRect().top + padding - contentY);
54-
};
55-
56-
/**
57-
* Once the scroll has come to rest, re-measure and instantly remove whatever
58-
* error remains. A single scrollTo cannot be pixel-perfect: any layout shift
59-
* while the animation runs — an image decoding, a font swapping, dvh settling —
60-
* moves the target by exactly the amount the landing ends up off by.
61-
*
62-
* Cancelled the moment the reader scrolls themselves, so the correction can
63-
* never yank the page away from someone who changed their mind mid-flight.
64-
*/
65-
const settleOnArrival = (target: Element) => {
66-
let cancelled = false;
67-
68-
const cancel = () => {
69-
cancelled = true;
70-
cleanup();
71-
};
72-
73-
const cleanup = () => {
74-
window.removeEventListener('wheel', cancel);
75-
window.removeEventListener('touchstart', cancel);
76-
window.removeEventListener('keydown', cancel);
77-
window.removeEventListener('scrollend', onEnd);
78-
};
79-
80-
const correct = () => {
81-
if (cancelled) return;
82-
const error = landingError(target);
83-
if (Math.abs(error) > 1) window.scrollBy({ top: error, behavior: 'auto' });
84-
};
85-
86-
const onEnd = () => {
87-
cleanup();
88-
correct();
89-
};
90-
91-
window.addEventListener('wheel', cancel, { passive: true, once: true });
92-
window.addEventListener('touchstart', cancel, { passive: true, once: true });
93-
window.addEventListener('keydown', cancel, { once: true });
94-
95-
if ('onscrollend' in window) {
96-
window.addEventListener('scrollend', onEnd, { once: true });
97-
} else {
98-
// Safari has no scrollend: treat three frames without movement as arrival.
99-
let last = -1;
100-
let still = 0;
101-
const tick = () => {
102-
if (cancelled) return;
103-
const y = window.scrollY;
104-
if (Math.abs(y - last) < 1) {
105-
if (++still >= 3) {
106-
cleanup();
107-
correct();
108-
return;
109-
}
110-
} else {
111-
still = 0;
112-
}
113-
last = y;
114-
requestAnimationFrame(tick);
115-
};
116-
requestAnimationFrame(tick);
117-
}
118-
};
119-
120-
const scrollToTarget = (target: Element, smooth: boolean) => {
121-
const top = window.scrollY + landingError(target);
122-
window.scrollTo({ top: Math.max(top, 0), behavior: smooth ? 'smooth' : 'auto' });
123-
settleOnArrival(target);
124-
};
125-
12621
type SiteHeaderProps = {
12722
title: string;
12823
};
@@ -138,49 +33,31 @@ export default function SiteHeader({ title }: SiteHeaderProps) {
13833

13934
const close = useCallback(() => setOpen(false), []);
14035

36+
useHashLanding();
37+
14138
/**
142-
* Scrolls to a section explicitly rather than leaving it to the browser's
143-
* anchor jump: the jump cannot skip a section's own top padding, and the
144-
* settle pass in scrollToTarget is what makes the landing exact.
39+
* Takes over the anchor jump, which cannot skip a section's own top padding.
40+
* The landing itself is `scrollToSection`'s problem, not the header's.
14541
*/
14642
const goToSection = useCallback(
14743
(event: MouseEvent<HTMLAnchorElement>, href: string, fromDrawer = false) => {
14844
// Leave modified clicks alone so open-in-new-tab still works.
14945
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
15046

151-
const target = document.querySelector(href);
47+
const target = findSection(href);
15248
if (!target) return;
15349

15450
event.preventDefault();
15551
close();
15652
if (fromDrawer) toggleRef.current?.focus();
15753

15854
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
159-
scrollToTarget(target, !reduced);
55+
scrollToSection(target, { smooth: !reduced });
16056
window.history.pushState(null, '', href);
16157
},
16258
[close],
16359
);
16460

165-
// Arriving with a hash in the URL takes the browser's native jump, which
166-
// lands on the border box with the fallback offset. Correct it once layout
167-
// has something real to measure.
168-
useEffect(() => {
169-
const { hash } = window.location;
170-
if (!hash || hash === '#top') return;
171-
172-
let target: Element | null = null;
173-
try {
174-
target = document.querySelector(hash);
175-
} catch {
176-
return; // Not a valid selector — an external tool's tracking hash.
177-
}
178-
if (!target) return;
179-
180-
const frame = requestAnimationFrame(() => scrollToTarget(target, false));
181-
return () => cancelAnimationFrame(frame);
182-
}, []);
183-
18461
// Slide the indicator behind the active link. Measured rather than expressed
18562
// in CSS because the links are content-width, so the pill's offset and width
18663
// are only knowable from layout.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
5+
import { findSection, scrollToSection } from '@/lib/scrollToSection';
6+
7+
/**
8+
* Corrects the browser's native hash jump on first load.
9+
*
10+
* Arriving at `/#roadmap` scrolls before the page can say where that section
11+
* should land: the jump uses `scroll-padding-top` against the section's border
12+
* box, so it cannot skip the section's own top padding, and the header height
13+
* it clears is still the fallback constant. Re-running the real landing once
14+
* layout exists puts it where a nav click would.
15+
*/
16+
export default function useHashLanding() {
17+
useEffect(() => {
18+
const { hash } = window.location;
19+
if (!hash || hash === '#top') return;
20+
21+
const target = findSection(hash);
22+
if (!target) return;
23+
24+
const frame = requestAnimationFrame(() => scrollToSection(target, { smooth: false }));
25+
return () => cancelAnimationFrame(frame);
26+
}, []);
27+
}

website/src/lib/scrollToSection.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Landing a section exactly under the floating header.
3+
*
4+
* Lives outside the header component because none of it is header UI — it is
5+
* page navigation, driven by the header but also by a hash in the URL.
6+
*/
7+
8+
/** Frames of stillness treated as "the scroll has stopped", where scrollend is absent. */
9+
const STILL_FRAMES = 3;
10+
/** Below this, the landing is already exact and correcting would only jitter. */
11+
const TOLERANCE_PX = 1;
12+
13+
/**
14+
* How far `target` is from where it should land, in px.
15+
*
16+
* Two cases, because the header is a floating pill and not an opaque bar —
17+
* nothing hides the strip of viewport around it, so a landing must never leave
18+
* the previous section visible in that strip:
19+
*
20+
* - A section with a full-viewport pinned scene lands flush at the viewport
21+
* top. The scene owns the whole screen and pads its own content clear of the
22+
* glass.
23+
* - Any other section lands with its content resting at the anchor offset when
24+
* its own top padding is deep enough to reach the border, and at the header's
25+
* edge otherwise — never higher, so the heading cannot tuck under the glass,
26+
* and never lower than its padding can cover, so the previous section's tail
27+
* cannot show above the border.
28+
*
29+
* The offset is read back from the resolved `scroll-padding-top`: custom
30+
* properties do not resolve calc() through getComputedStyle, real properties
31+
* do, so this is the one place the number exists and JS and CSS cannot drift.
32+
*/
33+
export const landingError = (target: Element): number => {
34+
const pin = target.querySelector('.pin');
35+
if (pin && getComputedStyle(pin).position === 'sticky') {
36+
return Math.round(target.getBoundingClientRect().top);
37+
}
38+
39+
const root = getComputedStyle(document.documentElement);
40+
const offset = parseFloat(root.scrollPaddingTop) || 0;
41+
const gap = parseFloat(root.getPropertyValue('--anchor-gap')) || 0;
42+
const headerHeight = offset - gap;
43+
44+
const padding = parseFloat(getComputedStyle(target).paddingTop) || 0;
45+
const contentY = Math.max(headerHeight, Math.min(padding, offset));
46+
47+
return Math.round(target.getBoundingClientRect().top + padding - contentY);
48+
};
49+
50+
/**
51+
* Once the scroll has come to rest, re-measure and instantly remove whatever
52+
* error remains. A single scrollTo cannot be pixel-perfect: any layout shift
53+
* while the animation runs — an image decoding, a font swapping, dvh settling —
54+
* moves the target by exactly the amount the landing ends up off by.
55+
*
56+
* Cancelled the moment the reader scrolls themselves, so the correction can
57+
* never yank the page away from someone who changed their mind mid-flight.
58+
*/
59+
const settleOnArrival = (target: Element) => {
60+
let cancelled = false;
61+
62+
const cleanup = () => {
63+
window.removeEventListener('wheel', cancel);
64+
window.removeEventListener('touchstart', cancel);
65+
window.removeEventListener('keydown', cancel);
66+
window.removeEventListener('scrollend', onEnd);
67+
};
68+
69+
function cancel() {
70+
cancelled = true;
71+
cleanup();
72+
}
73+
74+
const correct = () => {
75+
if (cancelled) return;
76+
const error = landingError(target);
77+
if (Math.abs(error) > TOLERANCE_PX) window.scrollBy({ top: error, behavior: 'auto' });
78+
};
79+
80+
function onEnd() {
81+
cleanup();
82+
correct();
83+
}
84+
85+
window.addEventListener('wheel', cancel, { passive: true, once: true });
86+
window.addEventListener('touchstart', cancel, { passive: true, once: true });
87+
window.addEventListener('keydown', cancel, { once: true });
88+
89+
if ('onscrollend' in window) {
90+
window.addEventListener('scrollend', onEnd, { once: true });
91+
return;
92+
}
93+
94+
// Safari has no scrollend: treat a few frames without movement as arrival.
95+
let last = -1;
96+
let still = 0;
97+
98+
const tick = () => {
99+
if (cancelled) return;
100+
101+
const y = window.scrollY;
102+
if (Math.abs(y - last) < 1) {
103+
if (++still >= STILL_FRAMES) {
104+
cleanup();
105+
correct();
106+
return;
107+
}
108+
} else {
109+
still = 0;
110+
}
111+
112+
last = y;
113+
requestAnimationFrame(tick);
114+
};
115+
116+
requestAnimationFrame(tick);
117+
};
118+
119+
/** Scrolls `target` to its landing position, then corrects once it settles. */
120+
export const scrollToSection = (target: Element, { smooth = true } = {}): void => {
121+
const top = window.scrollY + landingError(target);
122+
window.scrollTo({ top: Math.max(top, 0), behavior: smooth ? 'smooth' : 'auto' });
123+
settleOnArrival(target);
124+
};
125+
126+
/**
127+
* Resolves an in-page href to an element, tolerating the tracking hashes some
128+
* external tools append, which are not valid selectors.
129+
*/
130+
export const findSection = (href: string): Element | null => {
131+
try {
132+
return document.querySelector(href);
133+
} catch {
134+
return null;
135+
}
136+
};

0 commit comments

Comments
 (0)