Skip to content

Instantly share code, notes, and snippets.

@Wxh16144
Created November 12, 2025 05:31
Show Gist options
  • Select an option

  • Save Wxh16144/e2498888fc009d5bd3fd071fc54493b1 to your computer and use it in GitHub Desktop.

Select an option

Save Wxh16144/e2498888fc009d5bd3fd071fc54493b1 to your computer and use it in GitHub Desktop.
基于 React 和 react-intersection-observer 实现的滚动监听(Scroll Spy)组件示例,支持菜单与内容区块的联动高亮
import React, { useRef, useState, useContext, createContext, useCallback, useMemo } from "react";
import { useInView } from "react-intersection-observer";
// 1. Create a single context to hold everything
type SpyContextValue = {
root: HTMLElement | null;
onRatioChange: (key: string, ratio: number) => void;
};
const SpyContext = createContext<SpyContextValue>({
root: null,
onRatioChange: () => {},
});
type SectionMeta = { key: string; label: string };
/**
* 1. A simpler hook that only manages scroll-spy logic.
* It receives an `onUpdate` callback from the parent to report the new active key.
*/
function useScrollSpy(
sectionKeys: string[],
onUpdate: (key: string) => void
) {
const suppressRef = useRef(false);
const ratiosRef = useRef<Record<string, number>>({});
const handleRatioChange = useCallback((key: string, ratio: number) => {
ratiosRef.current[key] = ratio;
if (suppressRef.current) return;
let newActiveKey = sectionKeys[0] || '';
for (const k of sectionKeys) {
if ((ratiosRef.current[k] ?? 0) > 0.2) {
newActiveKey = k;
break;
}
}
onUpdate(newActiveKey);
}, [sectionKeys, onUpdate]);
// Expose a function to temporarily disable the scroll-spy
const suppressWhile = useCallback((action: () => void) => {
suppressRef.current = true;
action();
setTimeout(() => {
suppressRef.current = false;
}, 800); // Adjust timeout to match scroll animation
}, []);
return { handleRatioChange, suppressWhile };
}
// 4. Update InViewSection to consume the single context
function InViewSection({
id,
children,
}: {
id: string;
children: React.ReactNode;
}) {
const { root, onRatioChange } = useContext(SpyContext); // Get both from one context
const { ref } = useInView({
root,
threshold: [0, 0.2, 0.4, 0.6, 0.8, 1.0],
rootMargin: "0px 0px -20% 0px",
onChange: (inView, entry) => {
if (entry) {
onRatioChange(entry.target.id, entry.intersectionRatio ?? 0);
}
},
});
// The wrapper now applies the id and ref
return (
<div id={id} ref={ref}>
{children}
</div>
);
}
// --- Your Custom Components ---
const ComponentA = () => (
<div style={{ background: 'lightcyan', padding: 20, height: 400, marginBottom: 20 }}>
<h3>Component A</h3>
</div>
);
const ComponentB = () => (
<div style={{ background: 'lightgoldenrodyellow', padding: 20, height: 500, marginBottom: 20 }}>
<h3>Component B</h3>
</div>
);
const ComponentC = () => (
<div style={{ background: 'lightpink', padding: 20, height: 600, marginBottom: 20 }}>
<h3>Component C</h3>
</div>
);
// This array is for the menu and the hook, not for rendering
const mySections: SectionMeta[] = [
{ key: "comp-a", label: "Component A" },
{ key: "comp-b", label: "Component B" },
{ key: "comp-c", label: "Component C" },
];
/**
* 2. The parent component now holds all the state and primary logic.
*/
export default function LinkedMenu() {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [activeKey, setActiveKey] = useState<string>(mySections[0]?.key ?? "");
const { handleRatioChange, suppressWhile } = useScrollSpy(
mySections.map(s => s.key),
(key) => setActiveKey(key) // The hook calls this to update parent's state
);
const handleMenuClick = (key: string) => {
const el = document.getElementById(key);
if (!el) return;
// Use the suppress function from the hook
suppressWhile(() => {
setActiveKey(key); // Parent updates its own state
el.scrollIntoView({ behavior: "smooth", block: "start" });
});
};
// Memoize the context value to prevent unnecessary re-renders
const contextValue = useMemo(() => ({
root: container,
onRatioChange: handleRatioChange,
}), [container, handleRatioChange]);
return (
<div style={{ display: "flex", gap: 16 }}>
<nav style={{ width: 200 }}>
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{mySections.map((s) => (
<li
key={s.key}
onClick={() => handleMenuClick(s.key)}
style={{
padding: "8px 12px",
cursor: "pointer",
background: s.key === activeKey ? "#e6f7ff" : undefined,
fontWeight: s.key === activeKey ? 600 : 400,
borderRadius: 4,
}}
>
{s.label}
</li>
))}
</ul>
</nav>
<div
ref={setContainer} // A simple state setter is enough here
style={{
height: 600,
overflow: "auto",
flex: 1,
paddingRight: 8,
border: "1px solid #eee",
}}
>
{/* 3. Provide the single context value */}
<SpyContext.Provider value={contextValue}>
<InViewSection id="comp-a">
<ComponentA />
</InViewSection>
<InViewSection id="comp-b">
<ComponentB />
</InViewSection>
<InViewSection id="comp-c">
<ComponentC />
</InViewSection>
</SpyContext.Provider>
</div>
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment