Affix 吸附

让工具栏、操作区或分组标题在滚动时留在可视区域边缘。

顶部偏移

向下滚动检查清单。工具栏到达指定偏移后会停住,勾选进度始终可见。

发布前检查 · 0/6

随内容滚动

吸附不会移动或重建工具栏里的控件。

'use client'

import { useState } from 'react'
import {
  Affix,
  Card,
  Checkbox,
  Inline,
  NumberInput,
  ScrollArea,
  Stack,
  Switch,
  Tag,
  Text,
} from '@hina-ui/react'
import { affixChecklist } from '../../affix'

const items = affixChecklist('zh-CN')

export default function Demo() {
  const [disabled, setDisabled] = useState(false)
  const [offset, setOffset] = useState<number | undefined>(12)
  const [checked, setChecked] = useState<boolean[]>(Array(6).fill(false))
  const completed = checked.filter(Boolean).length
  return (
    <Stack className="w-full max-w-xl">
      <Inline align="center" gap="lg" wrap>
        <Switch checked={disabled} onCheckedChange={setDisabled}>
          禁用吸附
        </Switch>
        <Inline align="center" gap="sm">
          <Text size="sm" tone="muted">
            顶部偏移
          </Text>
          <NumberInput
            value={offset}
            onValueChange={setOffset}
            min={0}
            max={40}
            step={4}
            size="sm"
            aria-label="顶部偏移(px)"
            className="w-28"
          />
        </Inline>
      </Inline>
      <Card padded={false}>
        <ScrollArea className="h-80" shadow={false} focusable label="发布前检查清单">
          <Stack className="p-4" gap="lg">
            <Text size="sm" tone="muted">
              向下滚动检查清单。工具栏到达指定偏移后会停住,勾选进度始终可见。
            </Text>
            <Affix offset={offset} disabled={disabled}>
              {({ affixed }) => (
                <Card className={`p-3 ${affixed ? 'shadow-md' : 'shadow-none'}`}>
                  <Inline align="center" justify="between" gap="sm" wrap>
                    <Text size="sm" weight="medium">
                      发布前检查 · {completed}/6
                    </Text>
                    <Tag size="sm" tone={affixed ? 'accent' : 'neutral'}>
                      {affixed ? '已吸附' : '随内容滚动'}
                    </Tag>
                  </Inline>
                </Card>
              )}
            </Affix>
            {items.map((item, index) => (
              <Checkbox
                key={item.title}
                checked={checked[index]}
                onCheckedChange={value =>
                  setChecked(checked =>
                    checked.map((item, i) => (i === index ? value === true : item)),
                  )
                }
                description={item.description}
                block
              >
                {item.title}
              </Checkbox>
            ))}
            <Text size="xs" tone="muted">
              吸附不会移动或重建工具栏里的控件。
            </Text>
          </Stack>
        </ScrollArea>
      </Card>
    </Stack>
  )
}
tsx

用法

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

将需要吸附的内容放进 Affix。默认贴到最近滚动区域的顶部,offset 设置与边缘的距离,单位 px。

<Affix offset={16}>
  <Card>工具栏或操作区</Card>
</Affix>
tsx

Affix 使用原生 position: sticky,保留原来的布局空间、宽度和 DOM 节点。它不添加卡片、背景或滚动容器,也不为吸附加入位移、缩放或淡入淡出动画。外观由传入的内容决定。

示例

顶部偏移与禁用

顶部示例可以调整偏移、禁用吸附并勾选检查项。disabled 使内容恢复普通流,保留内部状态。将 children 写成接收 { affixed } 的函数,或使用 onChange,获取当前是否贴到指定边缘;例如给已吸附的工具栏添加阴影。

<Affix offset={16} disabled={disabled}>
  {({ affixed }) => <Card className={affixed ? 'shadow-md' : 'shadow-none'}>操作区</Card>}
</Affix>
tsx

状态改变时尽量只调整颜色、边框颜色或阴影,避免改变高度和外边距,反复推动吸附阈值。

底部操作区

将底部操作区放在它自然应该出现的位置,设置 position="bottom"。当自然位置位于可视区域下方时,它留在底部;滚到原位置后与内容一起移动。底部吸附仍保留在当前父区域内。

编辑文章

在列表中展示的一段简短介绍。

为下一位编辑保留上下文。

修改仅保留在当前示例

'use client'

import { useState } from 'react'
import {
  Affix,
  Button,
  Card,
  FormField,
  Heading,
  Inline,
  Input,
  ScrollArea,
  Stack,
  Text,
  Textarea,
} from '@hina-ui/react'

export default function Demo() {
  const [draft, setDraft] = useState({
    title: '组件设计回顾',
    author: '设计团队',
    summary: '',
    notes: '',
  })
  const [saved, setSaved] = useState(false)
  const update = (patch: Partial<typeof draft>) => {
    setDraft(draft => ({ ...draft, ...patch }))
    setSaved(false)
  }
  return (
    <Card padded={false} className="w-full max-w-xl">
      <ScrollArea className="h-96" shadow={false} focusable label="编辑文章">
        <Stack className="p-4" gap="lg">
          <Heading level={3} size="base">
            编辑文章
          </Heading>
          <FormField label="标题">
            <Input value={draft.title} onValueChange={title => update({ title })} />
          </FormField>
          <FormField label="作者">
            <Input value={draft.author} onValueChange={author => update({ author })} />
          </FormField>
          <FormField label="摘要" description="在列表中展示的一段简短介绍。">
            <Textarea
              value={draft.summary}
              onValueChange={summary => update({ summary })}
              rows={4}
              placeholder="这一篇主要讨论什么?"
            />
          </FormField>
          <FormField label="编辑备注" description="为下一位编辑保留上下文。">
            <Textarea
              value={draft.notes}
              onValueChange={notes => update({ notes })}
              rows={4}
              placeholder="补充修改原因或待确认事项"
            />
          </FormField>
          <Affix position="bottom" offset={12}>
            <Card className="p-3">
              <Inline align="center" justify="between" gap="sm" wrap>
                <Text role="status" size="sm" tone="muted">
                  {saved ? '草稿已保存在当前示例' : '修改仅保留在当前示例'}
                </Text>
                <Button
                  size="sm"
                  disabled={!draft.title.trim() || saved}
                  onClick={() => setSaved(true)}
                >
                  保存草稿
                </Button>
              </Inline>
            </Card>
          </Affix>
        </Stack>
      </ScrollArea>
    </Card>
  )
}
tsx

父区域边界

每个 Affix 受父布局区域约束。下面每个标题只在自己的分组范围内停留,分组结束时自动离开,不会盖住后面的内容。

每组标题只留在自己的区域内。滚到下一组时,上一组标题会一起离开。

设计与内容

内容与文案

标题清楚,错误信息给出恢复方法,空状态提供下一步入口。

键盘操作

检查 Tab 顺序、焦点可见性,以及浮层关闭后的焦点恢复。

窄屏布局

用较窄的窗口检查长标题、工具栏换行和内容是否横向溢出。

运行与适配

加载与失败

慢速网络下保留现有内容,失败后能够重试,避免清空已经填写的字段。

主题与方向

检查深色模式的对比度,以及 RTL 下图标、间距和文本的排列。

首屏与水合

刷新页面,确认内容在脚本执行前可读,水合后没有意外跳动。

import { Affix, Card, Heading, ScrollArea, Stack, Text } from '@hina-ui/react'
import { affixChecklist } from '../../affix'

const items = affixChecklist('zh-CN')
const groups = [
  { title: '设计与内容', items: items.slice(0, 3) },
  { title: '运行与适配', items: items.slice(3) },
]

export default function Demo() {
  return (
    <Stack className="w-full max-w-xl" gap="sm">
      <Text size="sm" tone="muted">
        每组标题只留在自己的区域内。滚到下一组时,上一组标题会一起离开。
      </Text>
      <Card padded={false}>
        <ScrollArea className="h-72" shadow={false} focusable label="分组检查说明">
          <Stack gap="none">
            {groups.map(group => (
              <Stack key={group.title} gap="none">
                <Affix>
                  <Heading
                    level={3}
                    size="sm"
                    className="bg-surface border-line border-b px-4 py-3"
                  >
                    {group.title}
                  </Heading>
                </Affix>
                {group.items.map(item => (
                  <Stack key={item.title} className="p-4" gap="sm">
                    <Text size="sm" weight="medium">
                      {item.title}
                    </Text>
                    <Text size="sm" tone="muted">
                      {item.description}
                    </Text>
                  </Stack>
                ))}
              </Stack>
            ))}
          </Stack>
        </ScrollArea>
      </Card>
    </Stack>
  )
}
tsx

滚动容器与布局

  • 自动使用最近具有滚动机制的祖先;没有这类祖先时跟随页面视口。放进 ScrollArea 的内容区即可,不需要读取 viewport 或传入 target。
  • 父区域需要留有移动空间。不要给 Affix 外面包一个仅与它等高的容器,否则没有可吸附的行程。横向 Flex 中需要侧栏保持自然高度时,给 Affix 设置 self-start,不要将它拉伸到整列高度。
  • overflow: auto / scroll / hidden 会改变吸附参照;用于裁剪但不希望建立滚动区域时,可在适用场景使用 overflow: clip。不要跨过一个不滚动的 overflow: hidden 祖先去期待页面级吸附。
  • 自动跟随容器宽度,保留原来的裁剪、方向和层叠关系。默认层级为 z-10,可通过 className 调整。需要一直固定在窗口角落的操作入口使用 FloatButton。
<ScrollArea className="h-96">
  <Stack>
    <Affix offset={12}>
      <Card>筛选与批量操作</Card>
    </Affix>
    <DataList items={items} itemKey="id">
      {({ item }) => item.title}
    </DataList>
  </Stack>
</ScrollArea>
tsx

SSR 与性能

吸附由 CSS 完成,SSR 首屏与客户端使用相同布局,没有测量后切成 fixed 的阶段。即使 JavaScript 尚未执行,内容仍可读,吸附仍生效。

children 函数收到的 affixed 在服务端为 false,挂载后同步当前边缘状态。滚动与尺寸变化只用于状态检测,同一帧合并处理,状态未改变时不调用 onChange;不会逐帧写入定位或占位尺寸。父区域结束、离开指定边缘时状态恢复为 false。

API

Props

属性
类型
默认值
说明
as
string
'div'
根节点标签
position
'top' | 'bottom'
'top'
吸附边缘
offset
number
0
边缘偏移,单位 px;支持负值
disabled
boolean
false
停用吸附,恢复普通流
className
string
—
根节点样式;原生属性及 style 也传给根节点

内容属性

属性
参数
说明
children
{ affixed: boolean }
当前是否处在指定吸附边缘

回调

回调
参数
说明
onChange
affixed: boolean
边缘吸附状态发生变化时触发

Ref

名称
类型
说明
element
HTMLElement | undefined
根节点
affixed
boolean
当前边缘吸附状态
update
() => void
下一帧重新检测状态;普通滚动和尺寸变化自动处理

根节点提供 data-position、data-affixed 和 data-disabled,便于自定义样式。