Anchor 页内目录

页内目录,随滚动标出当前所在的小节。

简介

滚动此区域,右侧条目随之更新。

安装

滚动此区域,右侧条目随之更新。

用法

滚动此区域,右侧条目随之更新。

import { Anchor, Heading, Inline, ScrollArea, Section, Text } from '@hina-ui/react'

const sections = [
  { id: 'hero-intro', label: '简介' },
  { id: 'hero-install', label: '安装' },
  { id: 'hero-usage', label: '用法' },
]

export default function Demo() {
  return (
    <Inline gap="lg" align="start" className="w-full max-w-2xl">
      <ScrollArea className="border-line h-56 flex-1 rounded-lg border">
        {sections.map(s => (
          <Section id={s.id} key={s.id} className="min-h-40 p-4">
            <Heading level={3} size="sm">
              {s.label}
            </Heading>
            <Text size="sm" tone="muted">
              滚动此区域,右侧条目随之更新。
            </Text>
          </Section>
        ))}
      </ScrollArea>
      <Anchor items={sections} className="w-28 shrink-0" />
    </Inline>
  )
}
tsx

用法

import { Anchor } from '@hina-ui/react'
ts

Anchor 由 items 驱动,每一项的 id 对应页面中一个元素的 id,label 是目录上显示的文字。

它只渲染目录,不渲染内容。每个 id 必须能在文档中找到对应元素,否则该项既不会被跟踪,点击也不会有反应。

作品简介

这一节的正文。

登场角色

这一节的正文。

制作人员

这一节的正文。

import { Anchor, Heading, Inline, ScrollArea, Section, Text } from '@hina-ui/react'

const items = [
  { id: 'basic-a', label: '作品简介' },
  { id: 'basic-b', label: '登场角色' },
  { id: 'basic-c', label: '制作人员' },
]

export default function Demo() {
  return (
    <Inline gap="lg" align="start" className="w-full max-w-2xl">
      <ScrollArea className="border-line h-56 flex-1 rounded-lg border">
        {items.map(item => (
          <Section id={item.id} key={item.id} className="min-h-40 p-4">
            <Heading level={3} size="sm">
              {item.label}
            </Heading>
            <Text size="sm" tone="muted">
              这一节的正文。
            </Text>
          </Section>
        ))}
      </ScrollArea>
      <Anchor items={items} className="w-28 shrink-0" />
    </Inline>
  )
}
tsx

它跟踪的是元素在视口中的可见性,因此内容位于页面主滚动区或 ScrollArea 之中,均可正常跟踪。

示例

层级

children 提供第二层条目,缩进一级显示。目录只支持两层——children 里再写 children 不会渲染。页内目录超过两层时,应当考虑拆分页面,而不是继续加深层级。

简介

这一节的正文。

路线

这一节的正文。

共通线

这一节的正文。

个人线

这一节的正文。

制作人员

这一节的正文。

import { Anchor, Heading, Inline, ScrollArea, Section, Text } from '@hina-ui/react'

const items = [
  { id: 'nest-intro', label: '简介' },
  {
    id: 'nest-route',
    label: '路线',
    children: [
      { id: 'nest-route-a', label: '共通线' },
      { id: 'nest-route-b', label: '个人线' },
    ],
  },
  { id: 'nest-staff', label: '制作人员' },
]

const flat = items.flatMap(item => [item, ...(item.children ?? [])])

export default function Demo() {
  return (
    <Inline gap="lg" align="start" className="w-full max-w-2xl">
      <ScrollArea className="border-line h-56 flex-1 rounded-lg border">
        {flat.map(item => (
          <Section id={item.id} key={item.id} className="min-h-32 p-4">
            <Heading level={3} size="sm">
              {item.label}
            </Heading>
            <Text size="sm" tone="muted">
              这一节的正文。
            </Text>
          </Section>
        ))}
      </ScrollArea>
      <Anchor items={items} className="w-32 shrink-0" />
    </Inline>
  )
}
tsx

尾部内容

renderTrailing 接收 { item, active },可放置 Tag、文字或图标等非交互内容。item 是原始条目,包含自定义字段;顶层与第二层条目均支持。

active 表示该项带有 aria-current="location"。多个小节同时可见时,仅目录顺序中的第一个可见项为 active,原有的可见区间高亮保持不变。

尾部靠行尾排列,不压缩自身宽度,长标题可换行。空的尾部不占位;更新状态标记不会重置当前项,点击标记仍由该行执行跳转。

条目 A

这一节的正文。

条目 B

这一节的正文。

子条目

这一节的正文。

'use client'

import { useState } from 'react'
import {
  Anchor,
  Button,
  Heading,
  Inline,
  ScrollArea,
  Section,
  Stack,
  Tag,
  Text,
} from '@hina-ui/react'

export default function Demo() {
  const [modified, setModified] = useState(true)
  const items = [
    { id: 'trailing-a', label: '条目 A', modified },
    {
      id: 'trailing-b',
      label: '条目 B',
      modified: false,
      children: [{ id: 'trailing-c', label: '子条目', modified: true }],
    },
  ]
  const sections = items.flatMap(item => [item, ...(item.children ?? [])])

  return (
    <Stack gap="md" className="w-full max-w-2xl">
      <Inline>
        <Button
          variant="soft"
          tone="neutral"
          aria-pressed={modified}
          onClick={() => setModified(!modified)}
        >
          切换条目 A 的标记
        </Button>
      </Inline>
      <Inline gap="lg" align="start" wrap={false}>
        <ScrollArea className="border-line h-56 min-w-0 flex-1 rounded-lg border">
          {sections.map(item => (
            <Section id={item.id} key={item.id} className="min-h-40 p-4">
              <Heading level={3} size="sm">
                {item.label}
              </Heading>
              <Text size="sm" tone="muted">
                这一节的正文。
              </Text>
            </Section>
          ))}
        </ScrollArea>
        <Anchor
          items={items}
          label="带尾部标记的目录"
          className="w-44 shrink-0"
          renderTrailing={({ item, active }) =>
            item.modified ? <Tag tone={active ? 'accent' : 'neutral'}>已修改</Tag> : null
          }
        />
      </Inline>
    </Stack>
  )
}
tsx

长目录跟随

把长目录放入限定高度的 ScrollArea,当前高亮段超出目录可视区域时,Anchor 会自动滚动目录使整段完整可见,并保留少量边距。高亮段比可视区域更高时,退回跟随段首当前项,不在首尾之间来回滚动。它会识别最近的纵向滚动容器,也支持原生滚动容器或根元素自身滚动,无需传入 viewport。

已经完整可见的高亮段不会反复居中,手动翻看目录也不会被持续拉回。首次定位和尺寸调整直接对齐,后续高亮段首尾变化时平滑跟随;减弱动态效果下始终瞬时定位。自动跟随不会移动焦点,也不会滚动同时包含正文的外层容器。

通过 autoScroll={false} 关闭自动跟随,scroll-spy 和高亮仍然工作。onChange 接收当前条目的 id,组件 ref 上也可读取只读的 current,不需要观察 aria-current。下面的目录包含 51 项,可以独立滚动正文和目录,或关闭跟随作比较。

共 51 节

第 1 节 · 背景与目标

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 2 节 · 设计过程

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 3 节 · 实现细节

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 4 节 · 使用体验

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 5 节 · 总结与展望

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 6 节 · 背景与目标

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 7 节 · 设计过程

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 8 节 · 实现细节

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 9 节 · 使用体验

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 10 节 · 总结与展望

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 11 节 · 背景与目标

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 12 节 · 设计过程

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 13 节 · 实现细节

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 14 节 · 使用体验

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 15 节 · 总结与展望

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 16 节 · 背景与目标

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 17 节 · 设计过程

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 18 节 · 实现细节

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 19 节 · 使用体验

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 20 节 · 总结与展望

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 21 节 · 背景与目标

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 22 节 · 设计过程

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 23 节 · 实现细节

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 24 节 · 使用体验

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 25 节 · 总结与展望

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 26 节 · 背景与目标

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 27 节 · 设计过程

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 28 节 · 实现细节

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 29 节 · 使用体验

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 30 节 · 总结与展望

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 31 节 · 背景与目标

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 32 节 · 设计过程

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 33 节 · 实现细节

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 34 节 · 使用体验

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 35 节 · 总结与展望

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 36 节 · 背景与目标

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 37 节 · 设计过程

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 38 节 · 实现细节

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 39 节 · 使用体验

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 40 节 · 总结与展望

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 41 节 · 背景与目标

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 42 节 · 设计过程

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 43 节 · 实现细节

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 44 节 · 使用体验

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 45 节 · 总结与展望

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 46 节 · 背景与目标

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 47 节 · 设计过程

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 48 节 · 实现细节

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 49 节 · 使用体验

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 50 节 · 总结与展望

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

第 51 节 · 背景与目标

滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。

当前:尚未进入正文

'use client'

import { useState } from 'react'
import { Anchor, Heading, Inline, ScrollArea, Section, Stack, Switch, Text } from '@hina-ui/react'

const topics = ['背景与目标', '设计过程', '实现细节', '使用体验', '总结与展望']
const items = Array.from({ length: 51 }, (_, index) => ({
  id: `long-toc-${index + 1}`,
  label: `第 ${index + 1} 节 · ${topics[index % topics.length]}`,
}))

export default function Demo() {
  const [autoScroll, setAutoScroll] = useState(true)
  const [current, setCurrent] = useState<string>()
  const currentLabel = items.find(item => item.id === current)?.label

  return (
    <Stack className="w-full max-w-2xl">
      <Inline justify="between">
        <Switch checked={autoScroll} onCheckedChange={setAutoScroll}>
          目录自动跟随
        </Switch>
        <Text size="sm" tone="muted">
          共 51 节
        </Text>
      </Inline>
      <Inline align="start" gap="md" wrap={false}>
        <ScrollArea
          className="border-line h-80 min-w-0 flex-1 rounded-lg border"
          focusable
          label="文章正文"
        >
          {items.map(item => (
            <Section id={item.id} key={item.id} className="min-h-56 p-4">
              <Heading level={3} size="sm">
                {item.label}
              </Heading>
              <Text size="sm" tone="muted">
                滚动正文阅读后续小节,目录会让当前项保持可见。也可以独立滚动目录,查找其他章节。
              </Text>
            </Section>
          ))}
        </ScrollArea>
        <ScrollArea
          className="h-80 w-2/5 max-w-48 shrink-0"
          shadow={false}
          focusable
          label="文章目录滚动区域"
        >
          <Anchor
            items={items}
            autoScroll={autoScroll}
            label="长篇文章目录"
            onChange={setCurrent}
          />
        </ScrollArea>
      </Inline>
      <Text size="sm" tone="muted">
        当前:{currentLabel ?? '尚未进入正文'}
      </Text>
    </Stack>
  )
}
tsx

目录名称

Anchor 渲染为 nav 地标,带有随界面语言给出的无障碍名(简体中文为「本页目录」)。一个页面里有多个导航地标时,用 label 分别命名。

版本 1.2

这一版的更新内容。

版本 1.1

这一版的更新内容。

版本 1.0

这一版的更新内容。

import { Anchor, Heading, Inline, ScrollArea, Section, Text } from '@hina-ui/react'

const items = [
  { id: 'label-a', label: '版本 1.2' },
  { id: 'label-b', label: '版本 1.1' },
  { id: 'label-c', label: '版本 1.0' },
]

export default function Demo() {
  return (
    <Inline gap="lg" align="start" className="w-full max-w-2xl">
      <ScrollArea className="border-line h-56 flex-1 rounded-lg border">
        {items.map(item => (
          <Section id={item.id} key={item.id} className="min-h-40 p-4">
            <Heading level={3} size="sm">
              {item.label}
            </Heading>
            <Text size="sm" tone="muted">
              这一版的更新内容。
            </Text>
          </Section>
        ))}
      </ScrollArea>
      <Anchor items={items} label="版本目录" className="w-28 shrink-0" />
    </Inline>
  )
}
tsx

行为

  • 屏幕上同时出现多个小节时,它们一并标出,左侧的高亮条延展覆盖这一整段,而不是从中选取其一。
  • 高亮条的伸缩是连续位移,不是跳变。
  • 视口底部约 15% 的范围不计入可见,小节须真正进入阅读区域才会被标为当前位置。
  • 点击条目平滑滚动至对应位置,并替换地址栏中的锚点,但不新增历史记录。
  • 系统开启减弱动态效果时改为瞬时定位,不执行平滑滚动。

无障碍

  • 外层是 nav 地标,内部是链接列表。
  • 当前项带 aria-current="location"。这里用 location 而非 page:读者仍在同一个页面上,变化的是页内位置。
  • 条目是真链接,href 指向对应的锚点,可以复制、可以在新标签页打开。
  • 高亮条是纯装饰,位置信息由 aria-current 与文字加重共同承担,不依赖颜色。

API

Props

属性
类型
默认值
说明
items
T[]
必填
目录条目
label
string
取自界面语言
导航地标的无障碍名
autoScroll
boolean
true
跟随当前高亮段;整段过高时跟随段首
className
string
—
追加到根元素的类

AnchorItem

字段
类型
说明
id
string
目标元素的 id,不含 #
label
string
目录上显示的文字
children
AnchorItem[]
第二层条目,其 children 被忽略

内容属性

属性
参数
说明
renderTrailing
{ item, active: boolean }
每一项链接内部的尾部内容

T 从 items 推导,须包含 AnchorItem 的基础字段。item 保留顶层条目及其 children 的原始类型;父子项字段不同时可按字段或判别标记缩小类型。

回调

回调
参数
说明
onChange
current: string | undefined
当前项变化时触发,包括首次确定当前项;条目重置后没有当前项时为 undefined

Ref

属性
类型
说明
current
string | undefined
只读的当前条目 id,未确定当前项时为 undefined