# 锚点导航 Anchor > 长页面旁边一个目录,点哪一节就滚到哪一节,滚到哪一节目录就亮哪一节。 分类:导航 [全部组件和流程](https://www.dachuiai.com/llms.txt) · [导航选型规则](https://www.dachuiai.com/guides/navigation.md) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[导航选型规则](https://www.dachuiai.com/guides/navigation.md)或[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:4 节以上的长页面:长文章、文档、说明书、很长的产品详情。 - 不适合:只有两三节的短页面。 - 适合的网站:内容站、SaaS、官网、电商 - 放在:导航、内容区 - 手机上:要改:手机上放不下侧边目录,收成顶部一条能左右划的目录,或者不放。 - 性能代价:低:滚动时读一下各节的位置。 - 常搭配:[吸顶导航 Sticky Header](https://www.dachuiai.com/c/sticky-header.md)、[返回顶部 Back to Top](https://www.dachuiai.com/c/back-to-top.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:`npm i lucide-react` - 文件:原样存成 `anchor-nav.tsx`(比如 `src/components/anchor-nav.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/anchor-nav.tsx),比手抄准:`curl -fsSL -o src/components/anchor-nav.tsx https://www.dachuiai.com/code/anchor-nav.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`useScrollspy`、`AnchorNav` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 图片:示例用了本站的 [/tex/s1.jpg](https://www.dachuiai.com/tex/s1.jpg)、[/tex/s5.jpg](https://www.dachuiai.com/tex/s5.jpg)、[/tex/s3.jpg](https://www.dachuiai.com/tex/s3.jpg)、[/tex/s2.jpg](https://www.dachuiai.com/tex/s2.jpg)、[/tex/s6.jpg](https://www.dachuiai.com/tex/s6.jpg)。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 注意:useScrollspy(各节 id, 滚动盒子, 偏移) 返回当前在哪一节;AnchorNav 只负责画目录,active 和 onJump 由页面传。offset 要等于顶部吸顶导航的高度,不然点过去标题会被导航挡住。 - `ids` 要写成组件外面的常量,或者用 useMemo 包起来:每次渲染都传一个新写的数组,组件会以为内容变了,从头重来。 - 保留代码里 `prefers-reduced-motion` 的处理:用户开了「减弱动态效果」时内容照样完整显示。 - Next.js(有 app 文件夹):文件第一行加 `'use client';`。 - 用 JavaScript 不用 TypeScript:Vite 和 Next.js 都能直接用 .tsx 文件;也可以删掉类型标注,改名 .jsx。 - 没有 Tailwind:先装 Tailwind CSS v4(Vite 项目 `npm i tailwindcss @tailwindcss/vite`,vite 配置的 plugins 里加 `tailwindcss()`,入口引入的那个 CSS 文件(一般是 `src/index.css`)第一行写 `@import "tailwindcss";`),或者把类名改写成项目用的样式。 - 已经装了 Tailwind v3:别为了一个组件升级。类名大多通用,少数 v4 写法要换,比如焦点框 `outline-2` 在 v3 里要写成 `outline outline-2`。 - 不是 React(Vue、Svelte、小程序、纯 HTML):照着代码的做法改写,时长、曲线、参数保持一样。Wix、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 **useScrollspy** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `ids` | | 要盯着的各节 id | | `scroller` | | 滚动盒子的 ref;整页滚动时不传 | | `offset` | `120` | 偏移(px) | **AnchorNav** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `sections` | | 目录:每一节的 id 和 title | | `active` | | 当前选中的那一项(key) | | `onJump` | | 点目录时调用,参数是那一节的 id | ## 你可以说 > 长文章右边加一个 Anchor 锚点导航目录:点哪一节就平滑滚到哪一节,滚到哪一节目录就亮哪一节(Scrollspy)。 ## 完整代码 ```tsx import {useEffect, useRef, useState, type RefObject} from 'react'; /* --------------------------------------------------------------- * AnchorNav 锚点导航(含 Scrollspy) * 长页面旁边放一个目录,点哪一节就平滑滚到哪一节; * 滚到哪一节,目录里那一节就跟着亮起来(这个效果叫 Scrollspy)。 * 整页用时 scroller 不传;放在可滚动的盒子里时,传这个盒子。 * ------------------------------------------------------------- */ export type Section = {id: string; title: string}; export function useScrollspy(ids: string[], scroller?: RefObject, offset = 120) { const [active, setActive] = useState(ids[0]); useEffect(() => { const el = scroller?.current; const target = el ?? window; let raf = 0; // 最后一个"标题已经滚过顶部 offset 位置"的节,就是正在读的那一节 const read = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { const top = el ? el.getBoundingClientRect().top : 0; let cur = ids[0]; for (const id of ids) { const sec = (el ?? document).querySelector(`#${id}`); if (sec && sec.getBoundingClientRect().top - top <= offset) cur = id; } setActive(cur); }); }; read(); target.addEventListener('scroll', read, {passive: true}); return () => { target.removeEventListener('scroll', read); cancelAnimationFrame(raf); }; }, [ids, scroller, offset]); return [active, setActive] as const; } export function AnchorNav({sections, active, onJump}: {sections: Section[]; active: string; onJump: (id: string) => void}) { const i = Math.max(0, sections.findIndex((s) => s.id === active)); return ( ); } /* ---------------- 示例:露营新手指南 ---------------- */ const SECTIONS = [ {id: 'an-site', title: '选营地', img: '/tex/s1.jpg', text: '第一次露营,选有水有电、离停车场近的营地。地面要平,避开低洼处和大树下,下雨不积水、打雷更安全。'}, {id: 'an-tent', title: '搭帐篷', img: '/tex/s5.jpg', text: '先铺地布,再展开帐篷,四角地钉斜着打进去。风大的时候,迎风面放低,风绳拉紧。'}, {id: 'an-sleep', title: '睡得暖', img: '/tex/s3.jpg', text: '山里夜里比白天冷 10 度。睡垫比睡袋更重要,它挡住的是地面传上来的冷。'}, {id: 'an-food', title: '做顿饭', img: '/tex/s2.jpg', text: '卡式炉加一口锅就够了。提前把食材切好分袋装,到营地只需要加热。'}, {id: 'an-leave', title: '收营地', img: '/tex/s6.jpg', text: '垃圾全部带走,帐篷晾干再收。离开前回头看一眼,地上什么都不留下。'}, ]; export default function AnchorNavDemo() { const box = useRef(null); const ids = useRef(SECTIONS.map((s) => s.id)).current; const [active, setActive] = useScrollspy(ids, box); const jump = (id: string) => { const el = box.current?.querySelector(`#${id}`); if (!el || !box.current) return; const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches; box.current.scrollTo({top: el.offsetTop - 24, behavior: smooth ? 'smooth' : 'auto'}); setActive(id); }; return (
攻略

露营新手指南

{SECTIONS.map((s, i) => (

{i + 1}{s.title}

{s.text}

))}
); } ```